@detiq/crawler 1.1.19 → 1.1.21
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/dist/auth.js +12 -2
- package/dist/auth.js.map +2 -2
- package/dist/crawler.js +395 -155
- package/dist/crawler.js.map +3 -3
- package/dist/job-queue.js +46 -3
- package/dist/job-queue.js.map +2 -2
- package/dist/native-crawler.js +6 -6
- package/dist/native-crawler.js.map +2 -2
- package/package.json +1 -1
- package/src/auth.ts +13 -1
- package/src/crawler.ts +317 -60
- package/src/job-queue.ts +30 -3
- package/src/native-crawler.ts +6 -4
- package/types/crawler.d.ts +4 -0
- package/types/job-queue.d.ts +3 -1
package/src/crawler.ts
CHANGED
|
@@ -257,31 +257,67 @@ function normalizeAuthUrl(url: string): string {
|
|
|
257
257
|
}
|
|
258
258
|
|
|
259
259
|
async function capturePageHtml(page: any): Promise<string | null> {
|
|
260
|
+
let html: string | null = null;
|
|
261
|
+
|
|
260
262
|
try {
|
|
261
263
|
if (typeof page.content === 'function') {
|
|
262
|
-
const
|
|
263
|
-
if (typeof
|
|
264
|
+
const raw = await page.content();
|
|
265
|
+
if (typeof raw === 'string' && raw.trim()) html = raw;
|
|
264
266
|
}
|
|
265
267
|
} catch (err: any) {
|
|
266
268
|
logger.warn({ err: String(err?.message ?? err) }, '[crawler] page.content() failed; trying DOM evaluate fallback');
|
|
267
269
|
}
|
|
268
270
|
|
|
271
|
+
if (!html) {
|
|
272
|
+
try {
|
|
273
|
+
html = await page.evaluate(() => {
|
|
274
|
+
const type = document.doctype;
|
|
275
|
+
const doctype = type
|
|
276
|
+
? '<!DOCTYPE ' + type.name +
|
|
277
|
+
(type.publicId ? ' PUBLIC "' + type.publicId + '"' : '') +
|
|
278
|
+
(type.systemId ? ' "' + type.systemId + '"' : '') +
|
|
279
|
+
'>'
|
|
280
|
+
: '<!DOCTYPE html>';
|
|
281
|
+
return doctype + '\n' + (document.documentElement?.outerHTML ?? document.body?.outerHTML ?? '');
|
|
282
|
+
});
|
|
283
|
+
if (typeof html !== 'string' || !html.trim()) html = null;
|
|
284
|
+
} catch (err: any) {
|
|
285
|
+
logger.warn({ err: String(err?.message ?? err) }, '[crawler] DOM snapshot capture failed');
|
|
286
|
+
return null;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (!html) return null;
|
|
291
|
+
|
|
292
|
+
// Inline all accessible stylesheets so the snapshot renders correctly offline
|
|
293
|
+
// (srcDoc iframes have a null origin — external CSS loads fail cross-origin).
|
|
269
294
|
try {
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
'
|
|
277
|
-
|
|
278
|
-
|
|
295
|
+
html = await page.evaluate(() => {
|
|
296
|
+
// Strip script tags — execution is blocked by CSP/sandbox anyway; removing reduces size and avoids leaking inline tokens.
|
|
297
|
+
document.querySelectorAll('script').forEach((s: any) => s.remove());
|
|
298
|
+
for (const sheet of Array.from(document.styleSheets)) {
|
|
299
|
+
try {
|
|
300
|
+
// cssRules throws SecurityError for cross-origin sheets — skip those
|
|
301
|
+
const cssText = Array.from(sheet.cssRules).map((r: any) => r.cssText).join('\n');
|
|
302
|
+
const style = document.createElement('style');
|
|
303
|
+
style.textContent = cssText;
|
|
304
|
+
const owner = sheet.ownerNode as Element | null;
|
|
305
|
+
owner?.replaceWith(style);
|
|
306
|
+
} catch { /* cross-origin CDN sheet — leave <link> in place */ }
|
|
307
|
+
}
|
|
308
|
+
const doctype = (() => {
|
|
309
|
+
const dt = document.doctype;
|
|
310
|
+
return dt ? `<!DOCTYPE ${dt.name}>` : '<!DOCTYPE html>';
|
|
311
|
+
})();
|
|
312
|
+
return doctype + '\n' + (document.documentElement?.outerHTML ?? '');
|
|
279
313
|
});
|
|
280
|
-
|
|
314
|
+
if (typeof html !== 'string' || !html.trim()) html = null;
|
|
281
315
|
} catch (err: any) {
|
|
282
|
-
logger.warn({ err: String(err?.message ?? err) }, '[crawler]
|
|
283
|
-
|
|
316
|
+
logger.warn({ err: String(err?.message ?? err) }, '[crawler] CSS inlining failed; using raw HTML');
|
|
317
|
+
// html already has the raw value from page.content() — usable as-is
|
|
284
318
|
}
|
|
319
|
+
|
|
320
|
+
return html;
|
|
285
321
|
}
|
|
286
322
|
|
|
287
323
|
/** Launch Chrome on a random CDP port and return its WebSocket debugger URL. */
|
|
@@ -442,6 +478,10 @@ const TwoFASchema = z.object({
|
|
|
442
478
|
fieldLabel: z.string().optional(),
|
|
443
479
|
});
|
|
444
480
|
|
|
481
|
+
// Shared across all crawl phases (lite-worker, main BFS, P3a, P6).
|
|
482
|
+
// PHP/legacy apps render session-expired content inline without URL redirect.
|
|
483
|
+
const INLINE_AUTH_WALL_RE = /your login session (was|has been) expired|session (has )?expired[^.]*click|please (log ?in|sign ?in) to continue|you (are|were) logged out|your session has timed out/i;
|
|
484
|
+
|
|
445
485
|
export async function crawlProject(opts: {
|
|
446
486
|
tenantId: string;
|
|
447
487
|
projectId: string;
|
|
@@ -1086,6 +1126,14 @@ export async function crawlProject(opts: {
|
|
|
1086
1126
|
if (popupScreen) {
|
|
1087
1127
|
const cloudUrl = await uploadToCloud(popupShotPath, `${opts.tenantId}/${opts.projectId}/screenshots/${path.basename(popupShotPath)}`).catch(() => undefined);
|
|
1088
1128
|
await AppBrain.saveScreenshot(opts.tenantId, opts.projectId, popupScreen.id, popupShotPath, popupScreen.version, 'DESKTOP', cloudUrl).catch(() => {});
|
|
1129
|
+
// GAP 4: DOM snapshot for popup capture
|
|
1130
|
+
const popupDom = await capturePageHtml(popup);
|
|
1131
|
+
if (popupDom) {
|
|
1132
|
+
const popupDomPath = popupShotPath.replace(/\.(png|jpg|jpeg)$/i, '.dom.html');
|
|
1133
|
+
await fs.writeFile(popupDomPath, popupDom, 'utf8');
|
|
1134
|
+
const popupDomKey = `${opts.tenantId}/${opts.projectId}/dom-snapshots/${path.basename(popupDomPath)}`;
|
|
1135
|
+
AppBrain.updateScreenDomSnapshot(opts.tenantId, popupScreen.id, popupDomPath, await uploadToCloud(popupDomPath, popupDomKey).catch(() => undefined)).catch(() => {});
|
|
1136
|
+
}
|
|
1089
1137
|
const popupEls = await domFallbackElements(popup, popup).catch(() => []);
|
|
1090
1138
|
for (const el of popupEls) {
|
|
1091
1139
|
AppBrain.saveElement(opts.tenantId, opts.projectId, popupScreen.id, el.meaning, el.role, el.expectedData, el.notes, undefined, true, el.boundingRect, el.ariaState, el.parentLandmark).catch(() => {});
|
|
@@ -1267,7 +1315,7 @@ export async function crawlProject(opts: {
|
|
|
1267
1315
|
} catch { return ''; }
|
|
1268
1316
|
})
|
|
1269
1317
|
.filter((h: string, i: number, arr: string[]) => h && arr.indexOf(h) === i)
|
|
1270
|
-
.slice(0,
|
|
1318
|
+
.slice(0, 50);
|
|
1271
1319
|
}, _appOrigin).catch(() => [] as string[]);
|
|
1272
1320
|
|
|
1273
1321
|
for (const link of publicNavLinks) {
|
|
@@ -1307,6 +1355,14 @@ export async function crawlProject(opts: {
|
|
|
1307
1355
|
await page.screenshot({ path: shotPath, fullPage: true }).catch(() => {});
|
|
1308
1356
|
const cloudUrl = await uploadToCloud(shotPath, `${opts.tenantId}/${opts.projectId}/screenshots/${path.basename(shotPath)}`).catch(() => undefined);
|
|
1309
1357
|
await (AppBrain.saveScreenshot as any)(opts.tenantId, opts.projectId, screen.id, shotPath, screen.version, 'DESKTOP', cloudUrl).catch(() => {});
|
|
1358
|
+
// GAP 5: DOM snapshot for pre-auth public page discovery
|
|
1359
|
+
const pubDom = await capturePageHtml(page);
|
|
1360
|
+
if (pubDom) {
|
|
1361
|
+
const pubDomPath = shotPath.replace(/\.(png|jpg|jpeg)$/i, '.dom.html');
|
|
1362
|
+
await fs.writeFile(pubDomPath, pubDom, 'utf8');
|
|
1363
|
+
const pubDomKey = `${opts.tenantId}/${opts.projectId}/dom-snapshots/${path.basename(pubDomPath)}`;
|
|
1364
|
+
AppBrain.updateScreenDomSnapshot(opts.tenantId, screen.id, pubDomPath, await uploadToCloud(pubDomPath, pubDomKey).catch(() => undefined)).catch(() => {});
|
|
1365
|
+
}
|
|
1310
1366
|
// Save elements found in the pre-auth view
|
|
1311
1367
|
await AppBrain.clearScreenElements(opts.tenantId, opts.projectId, screen.id).catch(() => {});
|
|
1312
1368
|
for (const el of preAuthElements) {
|
|
@@ -1687,6 +1743,10 @@ export async function crawlProject(opts: {
|
|
|
1687
1743
|
let maxSeenTotal = 0; // track high-water mark so progress % never goes backwards
|
|
1688
1744
|
let lastEmittedPct = 20; // floor above auth-discovery + login phases (max ~19%)
|
|
1689
1745
|
const crawlLoopStart = Date.now();
|
|
1746
|
+
// Global crawl timeout — prevent BFS from hanging indefinitely on large apps.
|
|
1747
|
+
// Default 2 hours; override via CRAWL_GLOBAL_TIMEOUT_MS env var.
|
|
1748
|
+
const CRAWL_GLOBAL_TIMEOUT_MS = parseInt(process.env.CRAWL_GLOBAL_TIMEOUT_MS ?? '') || 2 * 60 * 60 * 1000;
|
|
1749
|
+
const isCrawlTimedOut = () => (Date.now() - crawlLoopStart) >= CRAWL_GLOBAL_TIMEOUT_MS;
|
|
1690
1750
|
const startUrl = page.url();
|
|
1691
1751
|
// Resume from previous segment's pending queue if provided
|
|
1692
1752
|
const resumeUrls = opts.resumeUrls ?? [];
|
|
@@ -1726,6 +1786,11 @@ export async function crawlProject(opts: {
|
|
|
1726
1786
|
];
|
|
1727
1787
|
const queued = new Set<string>(seedUrls.map(canonicalize));
|
|
1728
1788
|
const queue = new CrawlFrontier(opts.traversalStrategy ?? 'BFS', seedUrls, opts.bestFirstKeywords, opts.urlFilter);
|
|
1789
|
+
// Selective recrawl: block all link-discovery pushes so BFS only visits the requested URLs.
|
|
1790
|
+
// Constructor seeding already ran above — this no-op override fires only for BFS-loop discoveries.
|
|
1791
|
+
if (selective) {
|
|
1792
|
+
queue.push = () => {};
|
|
1793
|
+
}
|
|
1729
1794
|
const deduplicator = new ContentDeduplicator(parseFloat(process.env.CRAWLER_DEDUP_THRESHOLD ?? '') || 0.88);
|
|
1730
1795
|
const deadLinkTracker = new DeadLinkTracker();
|
|
1731
1796
|
const redirectRegistry = new RedirectRegistry();
|
|
@@ -1824,7 +1889,8 @@ export async function crawlProject(opts: {
|
|
|
1824
1889
|
logger.info({ count: g11SitemapUrls.length }, '[crawler] G11: sitemap discovered public URL candidates');
|
|
1825
1890
|
const sitemapOrigin = new URL(opts.appUrl).origin;
|
|
1826
1891
|
let sitemapCaptured = 0;
|
|
1827
|
-
for (const sUrl of g11SitemapUrls
|
|
1892
|
+
for (const sUrl of g11SitemapUrls) {
|
|
1893
|
+
if (isCrawlTimedOut()) break;
|
|
1828
1894
|
try {
|
|
1829
1895
|
const u = new URL(sUrl);
|
|
1830
1896
|
if (u.origin !== sitemapOrigin) continue;
|
|
@@ -1846,7 +1912,17 @@ export async function crawlProject(opts: {
|
|
|
1846
1912
|
await page.screenshot({ path: shotPath, fullPage: true }).catch(() => { });
|
|
1847
1913
|
const pubScreen = await AppBrain.saveScreen(opts.tenantId, opts.projectId, pageName, sUrl, opts.jobId, 'SITEMAP').catch(() => null);
|
|
1848
1914
|
if (pubScreen) {
|
|
1849
|
-
await
|
|
1915
|
+
const sitemapCloudUrl = await uploadToCloud(shotPath, `${opts.tenantId}/${opts.projectId}/screenshots/${path.basename(shotPath)}`).catch(() => undefined);
|
|
1916
|
+
await AppBrain.saveScreenshot(opts.tenantId, opts.projectId, pubScreen.id, shotPath, pubScreen.version, 'DESKTOP', sitemapCloudUrl).catch(() => { });
|
|
1917
|
+
// GAP 2: DOM snapshot for G11 pre-auth sitemap path
|
|
1918
|
+
const sitemapDom = await capturePageHtml(page);
|
|
1919
|
+
if (sitemapDom) {
|
|
1920
|
+
const sitemapDomPath = shotPath.replace(/\.(png|jpg|jpeg)$/i, '.dom.html');
|
|
1921
|
+
await fs.writeFile(sitemapDomPath, sitemapDom, 'utf8');
|
|
1922
|
+
const sitemapDomKey = `${opts.tenantId}/${opts.projectId}/dom-snapshots/${path.basename(sitemapDomPath)}`;
|
|
1923
|
+
const sitemapDomCloud = await uploadToCloud(sitemapDomPath, sitemapDomKey).catch(() => undefined);
|
|
1924
|
+
AppBrain.updateScreenDomSnapshot(opts.tenantId, pubScreen.id, sitemapDomPath, sitemapDomCloud).catch(() => {});
|
|
1925
|
+
}
|
|
1850
1926
|
logger.info({ url: sUrl, name: pageName }, '[crawler] G11: captured public sitemap page');
|
|
1851
1927
|
}
|
|
1852
1928
|
sitemapCaptured++;
|
|
@@ -1928,6 +2004,7 @@ export async function crawlProject(opts: {
|
|
|
1928
2004
|
async function runLiteWorker(workerPage: any): Promise<void> {
|
|
1929
2005
|
while (queue.length > 0 && visited.size < maxScreens && visited.size < segmentSize) {
|
|
1930
2006
|
if (opts.abortSignal?.aborted) break;
|
|
2007
|
+
if (isCrawlTimedOut()) break;
|
|
1931
2008
|
if (++totalAttemptsThisSegment > maxAttempts) break;
|
|
1932
2009
|
|
|
1933
2010
|
const url = queue.shift();
|
|
@@ -1999,6 +2076,18 @@ export async function crawlProject(opts: {
|
|
|
1999
2076
|
const screen = await AppBrain.saveScreen(opts.tenantId, opts.projectId, name, url, opts.jobId, discoveryMethodFor(url));
|
|
2000
2077
|
visited.set(canonUrl, screen.id);
|
|
2001
2078
|
|
|
2079
|
+
// Inline auth-wall detection: PHP/legacy apps render session-expired content without URL redirect.
|
|
2080
|
+
// URL-redirect case (lwIsAuthWall above) is handled by main BFS via revisitQueue.
|
|
2081
|
+
|
|
2082
|
+
const lwBodyText = await workerPage.evaluate(() => (document as any).body?.innerText ?? '').catch(() => '');
|
|
2083
|
+
if (INLINE_AUTH_WALL_RE.test(lwBodyText)) {
|
|
2084
|
+
logger.warn({ url, screenId: screen.id }, '[lite-worker] Inline session-expired content detected — marking requiresAuth');
|
|
2085
|
+
AppBrain.updateScreenRequiresAuth(opts.tenantId, screen.id, true).catch(() => {});
|
|
2086
|
+
visited.set(canonUrl, '_auth_wall_');
|
|
2087
|
+
if (!revisitQueue.includes(url)) revisitQueue.push(url);
|
|
2088
|
+
continue;
|
|
2089
|
+
}
|
|
2090
|
+
|
|
2002
2091
|
const shotPath = path.join(opts.screenshotDir ?? '/tmp', `${screen.id}-desktop-v${screen.version}.png`);
|
|
2003
2092
|
|
|
2004
2093
|
let liteElements = await domFallbackElements(workerPage).catch(() => [] as Awaited<ReturnType<typeof domFallbackElements>>);
|
|
@@ -2026,6 +2115,16 @@ export async function crawlProject(opts: {
|
|
|
2026
2115
|
.catch(() => {}),
|
|
2027
2116
|
]);
|
|
2028
2117
|
|
|
2118
|
+
// GAP 1: DOM snapshot for lite-worker path
|
|
2119
|
+
const lwDom = await capturePageHtml(workerPage);
|
|
2120
|
+
if (lwDom) {
|
|
2121
|
+
const lwDomPath = shotPath.replace(/\.(png|jpg|jpeg)$/i, '.dom.html');
|
|
2122
|
+
await fs.writeFile(lwDomPath, lwDom, 'utf8');
|
|
2123
|
+
const lwDomKey = `${opts.tenantId}/${opts.projectId}/dom-snapshots/${path.basename(lwDomPath)}`;
|
|
2124
|
+
const lwDomCloud = await uploadToCloud(lwDomPath, lwDomKey).catch(() => undefined);
|
|
2125
|
+
AppBrain.updateScreenDomSnapshot(opts.tenantId, screen.id, lwDomPath, lwDomCloud).catch(() => {});
|
|
2126
|
+
}
|
|
2127
|
+
|
|
2029
2128
|
// Nav-link pass — merged into main save cycle so clearScreenElements covers them too.
|
|
2030
2129
|
// extractElements (main BFS) never runs for lite-worker screens, so nav/footer roles
|
|
2031
2130
|
// must be captured here. Running before clear+save prevents additive accumulation.
|
|
@@ -2122,8 +2221,8 @@ export async function crawlProject(opts: {
|
|
|
2122
2221
|
// Persist lite-worker elements to DB — without this, 80% of screens have 0 DB elements.
|
|
2123
2222
|
if (allElements.length > 0) {
|
|
2124
2223
|
await AppBrain.clearScreenElements(opts.tenantId, opts.projectId, screen.id).catch(() => {});
|
|
2125
|
-
for (let _ei = 0; _ei < allElements.length; _ei +=
|
|
2126
|
-
const _batch = allElements.slice(_ei, _ei +
|
|
2224
|
+
for (let _ei = 0; _ei < allElements.length; _ei += 2) {
|
|
2225
|
+
const _batch = allElements.slice(_ei, _ei + 2);
|
|
2127
2226
|
await Promise.all(_batch.map((el: any) =>
|
|
2128
2227
|
AppBrain.saveElement(opts.tenantId, opts.projectId, screen.id, el.meaning, el.role, el.expectedData, el.notes, undefined, true, el.boundingRect, el.ariaState, el.parentLandmark).catch(() => {})
|
|
2129
2228
|
));
|
|
@@ -2235,6 +2334,12 @@ export async function crawlProject(opts: {
|
|
|
2235
2334
|
logger.info('Abort signal received — stopping crawl');
|
|
2236
2335
|
throw new Error('CRAWL_ABORTED');
|
|
2237
2336
|
}
|
|
2337
|
+
if (isCrawlTimedOut()) {
|
|
2338
|
+
const elapsedMin = Math.round((Date.now() - crawlLoopStart) / 60_000);
|
|
2339
|
+
logger.warn({ elapsedMin, visited: visited.size, queued: queue.length }, '[crawler] Global timeout reached — stopping BFS to prevent runaway crawl');
|
|
2340
|
+
await onProgress?.(89, `Crawl timeout after ${elapsedMin}min — ${visited.size} pages captured`).catch(() => {});
|
|
2341
|
+
break;
|
|
2342
|
+
}
|
|
2238
2343
|
// AutoThrottle: delay next request proportional to avg navigation latency
|
|
2239
2344
|
if (opts.autoThrottle && navCount > 0) {
|
|
2240
2345
|
const delayMs = Math.min(5_000, Math.max(0, avgNavMs * 0.5));
|
|
@@ -2475,7 +2580,17 @@ export async function crawlProject(opts: {
|
|
|
2475
2580
|
}).catch(() => 'Modal');
|
|
2476
2581
|
const modalScreen = await AppBrain.saveScreen(opts.tenantId, opts.projectId, modalName, url, opts.jobId, discoveryMethodFor(url)).catch(() => null);
|
|
2477
2582
|
if (modalScreen) {
|
|
2478
|
-
await
|
|
2583
|
+
const modalCloudUrl = await uploadToCloud(modalShotPath, `${opts.tenantId}/${opts.projectId}/screenshots/${path.basename(modalShotPath)}`).catch(() => undefined);
|
|
2584
|
+
await AppBrain.saveScreenshot(opts.tenantId, opts.projectId, modalScreen.id, modalShotPath, modalScreen.version, 'DESKTOP', modalCloudUrl).catch(() => { });
|
|
2585
|
+
// GAP 3: DOM snapshot for modal/dialog capture
|
|
2586
|
+
const modalDom = await capturePageHtml(page);
|
|
2587
|
+
if (modalDom) {
|
|
2588
|
+
const modalDomPath = modalShotPath.replace(/\.(png|jpg|jpeg)$/i, '.dom.html');
|
|
2589
|
+
await fs.writeFile(modalDomPath, modalDom, 'utf8');
|
|
2590
|
+
const modalDomKey = `${opts.tenantId}/${opts.projectId}/dom-snapshots/${path.basename(modalDomPath)}`;
|
|
2591
|
+
const modalDomCloud = await uploadToCloud(modalDomPath, modalDomKey).catch(() => undefined);
|
|
2592
|
+
AppBrain.updateScreenDomSnapshot(opts.tenantId, modalScreen.id, modalDomPath, modalDomCloud).catch(() => {});
|
|
2593
|
+
}
|
|
2479
2594
|
// Extract elements from the modal BEFORE it is dismissed — use CDP path via rawPage
|
|
2480
2595
|
const modalElements = await domFallbackElements(page, rawPage ?? undefined).catch(() => []);
|
|
2481
2596
|
for (const el of modalElements) {
|
|
@@ -2531,6 +2646,11 @@ export async function crawlProject(opts: {
|
|
|
2531
2646
|
reason: `Login wall detected: ${loginWallRedirectCount} consecutive URLs redirected to login. Add credentials in project settings to crawl authenticated pages.`,
|
|
2532
2647
|
},
|
|
2533
2648
|
}).catch(() => {});
|
|
2649
|
+
// Checkpoint remaining queue so job-queue can auto-requeue the pending URLs
|
|
2650
|
+
await opts.onCheckpoint?.(
|
|
2651
|
+
Array.from(visited.entries()).filter(([_k, v]) => v !== '_pending_').map(([k]) => k),
|
|
2652
|
+
queue.filter((u: string) => !visited.has(canonicalize(u)))
|
|
2653
|
+
).catch(() => {});
|
|
2534
2654
|
throw new Error(`[AUTH_FAILURE] Login wall detected: ${loginWallRedirectCount} URLs redirected to the login page. Add credentials in project settings and retry the crawl.`);
|
|
2535
2655
|
}
|
|
2536
2656
|
}
|
|
@@ -2562,6 +2682,11 @@ export async function crawlProject(opts: {
|
|
|
2562
2682
|
type: 'AUTH_FAILURE',
|
|
2563
2683
|
payload: { url, landed: landedUrl, reason: 'Re-authentication failed. Session is unrecoverable. Re-capture the session in project settings and retry the crawl.' },
|
|
2564
2684
|
}).catch(() => { });
|
|
2685
|
+
// Checkpoint remaining queue before aborting so job-queue can auto-requeue
|
|
2686
|
+
await opts.onCheckpoint?.(
|
|
2687
|
+
Array.from(visited.entries()).filter(([_k, v]) => v !== '_pending_').map(([k]) => k),
|
|
2688
|
+
queue.filter((u: string) => !visited.has(canonicalize(u)))
|
|
2689
|
+
).catch(() => {});
|
|
2565
2690
|
throw new Error('[AUTH_FAILURE] Re-authentication failed. Session is unrecoverable. Re-capture the session in project settings and retry the crawl.');
|
|
2566
2691
|
}
|
|
2567
2692
|
} else {
|
|
@@ -2578,6 +2703,11 @@ export async function crawlProject(opts: {
|
|
|
2578
2703
|
type: 'AUTH_FAILURE',
|
|
2579
2704
|
payload: { url, landed: landedUrl, reason: 'Session expired and no credentials are configured. Add credentials in project settings and re-capture the session.' },
|
|
2580
2705
|
}).catch(() => { });
|
|
2706
|
+
// Checkpoint remaining queue before aborting so job-queue can auto-requeue
|
|
2707
|
+
await opts.onCheckpoint?.(
|
|
2708
|
+
Array.from(visited.entries()).filter(([_k, v]) => v !== '_pending_').map(([k]) => k),
|
|
2709
|
+
queue.filter((u: string) => !visited.has(canonicalize(u)))
|
|
2710
|
+
).catch(() => {});
|
|
2581
2711
|
throw new Error('[AUTH_FAILURE] Session expired and no credentials are configured. Add credentials in project settings and re-capture the session.');
|
|
2582
2712
|
}
|
|
2583
2713
|
}
|
|
@@ -3256,6 +3386,20 @@ export async function crawlProject(opts: {
|
|
|
3256
3386
|
}
|
|
3257
3387
|
}
|
|
3258
3388
|
|
|
3389
|
+
// Inline auth-wall detection: PHP/legacy apps render session-expired content
|
|
3390
|
+
// without changing the URL, bypassing all URL-path-based guards above.
|
|
3391
|
+
const INLINE_AUTH_WALL_RE = /your login session (was|has been) expired|session (has )?expired[^.]*click|please (log ?in|sign ?in) to continue|you (are|were) logged out|your session has timed out/i;
|
|
3392
|
+
const pageBodyText = await page.evaluate(() => (document as any).body?.innerText ?? '').catch(() => '');
|
|
3393
|
+
if (INLINE_AUTH_WALL_RE.test(pageBodyText)) {
|
|
3394
|
+
logger.warn({ url, screenId: screen.id }, '[crawler] Inline session-expired content detected — skipping DOM snapshot, marking requiresAuth');
|
|
3395
|
+
AppBrain.updateScreenRequiresAuth(opts.tenantId, screen.id, true).catch(() => {});
|
|
3396
|
+
AppBrain.saveCrawlEvidence(opts.tenantId, opts.projectId, {
|
|
3397
|
+
screenId: screen.id, jobId: opts.jobId,
|
|
3398
|
+
type: 'AUTH_FAILURE',
|
|
3399
|
+
payload: { url, reason: 'Inline session-expired page rendered without URL redirect. Re-authenticate and re-crawl.' },
|
|
3400
|
+
}).catch(() => {});
|
|
3401
|
+
} else {
|
|
3402
|
+
|
|
3259
3403
|
// Capture DOM snapshot for structural analysis, and extract Markdown/text
|
|
3260
3404
|
// content from the same DOM (crawl4AI-parity text output) — no extra fetch.
|
|
3261
3405
|
try {
|
|
@@ -3265,7 +3409,12 @@ export async function crawlProject(opts: {
|
|
|
3265
3409
|
} else {
|
|
3266
3410
|
const domPath = shotPath.replace(/\.(png|jpg|jpeg)$/i, '.dom.html');
|
|
3267
3411
|
await fs.writeFile(domPath, domContent, 'utf8');
|
|
3268
|
-
|
|
3412
|
+
// Upload DOM snapshot to cloud alongside PNG, then persist both paths
|
|
3413
|
+
(async () => {
|
|
3414
|
+
const domKey = `${opts.tenantId}/${opts.projectId}/dom-snapshots/${path.basename(domPath)}`;
|
|
3415
|
+
const domCloudUrl = await uploadToCloud(domPath, domKey).catch(() => undefined);
|
|
3416
|
+
AppBrain.updateScreenDomSnapshot(opts.tenantId, screen.id, domPath, domCloudUrl).catch(() => {});
|
|
3417
|
+
})();
|
|
3269
3418
|
|
|
3270
3419
|
// Supplement markdown with iFrame and Shadow DOM content
|
|
3271
3420
|
const [iframeContent, shadowContent] = await Promise.all([
|
|
@@ -3374,6 +3523,7 @@ export async function crawlProject(opts: {
|
|
|
3374
3523
|
} catch (err: any) {
|
|
3375
3524
|
logger.warn({ url, screenId: screen.id, err: String(err?.message ?? err) }, '[crawler] markdown extraction failed');
|
|
3376
3525
|
}
|
|
3526
|
+
} // end: inline auth-wall else
|
|
3377
3527
|
|
|
3378
3528
|
// Persist a11y score + individual violations — fire-and-forget, non-fatal
|
|
3379
3529
|
if (a11yScore !== null) {
|
|
@@ -3568,6 +3718,7 @@ export async function crawlProject(opts: {
|
|
|
3568
3718
|
);
|
|
3569
3719
|
if (driftedToAuthWall) {
|
|
3570
3720
|
logger.warn({ url, postExtractUrl }, '[crawler] Page drifted to auth wall during element extraction — skipping element save to prevent login-page element contamination');
|
|
3721
|
+
AppBrain.updateScreenRequiresAuth(opts.tenantId, screen.id, true).catch(() => {});
|
|
3571
3722
|
AppBrain.saveCrawlEvidence(opts.tenantId, opts.projectId, {
|
|
3572
3723
|
screenId: screen.id,
|
|
3573
3724
|
jobId: opts.jobId,
|
|
@@ -3587,9 +3738,9 @@ export async function crawlProject(opts: {
|
|
|
3587
3738
|
} else {
|
|
3588
3739
|
// Clear existing elements before re-saving — prevents duplicates on re-crawl
|
|
3589
3740
|
await AppBrain.clearScreenElements(opts.tenantId, opts.projectId, screen.id).catch(() => {});
|
|
3590
|
-
// Batch element saves:
|
|
3591
|
-
for (let _ei = 0; _ei < elements.length; _ei +=
|
|
3592
|
-
const _batch = elements.slice(_ei, _ei +
|
|
3741
|
+
// Batch element saves: 2 concurrent DB writes (4 caused pool saturation at 5 workers)
|
|
3742
|
+
for (let _ei = 0; _ei < elements.length; _ei += 2) {
|
|
3743
|
+
const _batch = elements.slice(_ei, _ei + 2);
|
|
3593
3744
|
await Promise.all(_batch.map(async (el: any) => {
|
|
3594
3745
|
const saved = await AppBrain.saveElement(opts.tenantId, opts.projectId, screen.id, el.meaning, el.role, el.expectedData, el.notes, undefined, true, el.boundingRect, el.ariaState, el.parentLandmark);
|
|
3595
3746
|
// Populate ElementFingerprint — fire-and-forget, failures are non-fatal
|
|
@@ -3601,7 +3752,7 @@ export async function crawlProject(opts: {
|
|
|
3601
3752
|
{ type: 'aria-label', value: label, confidence: 0.85 },
|
|
3602
3753
|
{ type: 'tag-hint', value: selectorHint, confidence: 0.6 },
|
|
3603
3754
|
];
|
|
3604
|
-
AppBrain.saveElementFingerprint(opts.tenantId, opts.projectId, (saved as any).id, selectors).catch(console.warn);
|
|
3755
|
+
await AppBrain.saveElementFingerprint(opts.tenantId, opts.projectId, (saved as any).id, selectors).catch(console.warn);
|
|
3605
3756
|
}));
|
|
3606
3757
|
}
|
|
3607
3758
|
} // end inner else: elements.length > 0
|
|
@@ -3634,7 +3785,7 @@ export async function crawlProject(opts: {
|
|
|
3634
3785
|
isUsefulNavigationUrl(lm.href, origin) &&
|
|
3635
3786
|
!queued.has(canonLmHref) &&
|
|
3636
3787
|
!visited.has(canonLmHref) &&
|
|
3637
|
-
queued.size < maxScreens *
|
|
3788
|
+
queued.size < maxScreens * 3
|
|
3638
3789
|
) {
|
|
3639
3790
|
if (robotsCache && !await robotsCache.isAllowed(lm.href)) continue;
|
|
3640
3791
|
queued.add(canonLmHref);
|
|
@@ -3658,7 +3809,7 @@ export async function crawlProject(opts: {
|
|
|
3658
3809
|
if (/^yith-|^wc-|^vc_|^elementor/.test(actionVal)) continue;
|
|
3659
3810
|
} catch { /* invalid URL, let it fall through */ }
|
|
3660
3811
|
const canonHref = canonicalize(href);
|
|
3661
|
-
if (!queued.has(canonHref) && !visited.has(canonHref) && queued.size < maxScreens *
|
|
3812
|
+
if (!queued.has(canonHref) && !visited.has(canonHref) && queued.size < maxScreens * 3) {
|
|
3662
3813
|
// Don't flood the queue with generated ID-like route segments.
|
|
3663
3814
|
try {
|
|
3664
3815
|
const hrefPath = new URL(href).pathname;
|
|
@@ -3717,7 +3868,7 @@ export async function crawlProject(opts: {
|
|
|
3717
3868
|
const jsDetectedRoutes = await detectJsRoutes(page, origin);
|
|
3718
3869
|
for (const route of jsDetectedRoutes) {
|
|
3719
3870
|
const canonRoute = canonicalize(route);
|
|
3720
|
-
if (isUsefulNavigationUrl(route, origin) && !queued.has(canonRoute) && !visited.has(canonRoute) && queued.size < maxScreens *
|
|
3871
|
+
if (isUsefulNavigationUrl(route, origin) && !queued.has(canonRoute) && !visited.has(canonRoute) && queued.size < maxScreens * 3) {
|
|
3721
3872
|
queued.add(canonRoute);
|
|
3722
3873
|
queue.push(route, { parentElements: elements.length });
|
|
3723
3874
|
logger.info({ route }, '[P2] JS router: discovered route added to queue');
|
|
@@ -3733,7 +3884,7 @@ export async function crawlProject(opts: {
|
|
|
3733
3884
|
try {
|
|
3734
3885
|
const abs = `${origin}${relPath}`;
|
|
3735
3886
|
const canon = canonicalize(abs);
|
|
3736
|
-
if (isUsefulNavigationUrl(abs, origin) && !queued.has(canon) && !visited.has(canon) && queued.size < maxScreens *
|
|
3887
|
+
if (isUsefulNavigationUrl(abs, origin) && !queued.has(canon) && !visited.has(canon) && queued.size < maxScreens * 3) {
|
|
3737
3888
|
queued.add(canon);
|
|
3738
3889
|
queue.push(abs);
|
|
3739
3890
|
logger.info({ url: abs }, '[P4] XHR: page URL from JSON response added to queue');
|
|
@@ -3745,7 +3896,7 @@ export async function crawlProject(opts: {
|
|
|
3745
3896
|
// Triggered per-page when: zero DOM links found + fewer than 5 elements (dead-end / canvas page).
|
|
3746
3897
|
// Skyvern returns URLs it discovered → added to queue for ZeTa's own BFS crawl (not saved directly).
|
|
3747
3898
|
// ZeTa does all actual crawling — Skyvern is only a URL-discovery override layer.
|
|
3748
|
-
if (nextLinks.length === 0 && elements.length < (parseInt(process.env.CRAWL_SKYVERN_PAGE_MIN_ELEMENTS ?? '') || 5) && opts.skyvernConfig) {
|
|
3899
|
+
if (!selective && nextLinks.length === 0 && elements.length < (parseInt(process.env.CRAWL_SKYVERN_PAGE_MIN_ELEMENTS ?? '') || 5) && opts.skyvernConfig) {
|
|
3749
3900
|
try {
|
|
3750
3901
|
logger.info({ url }, 'Gap 5: thin page detected — asking Skyvern to discover navigation URLs');
|
|
3751
3902
|
const skyvernNav = await skyvernNavigate(
|
|
@@ -3755,7 +3906,7 @@ export async function crawlProject(opts: {
|
|
|
3755
3906
|
);
|
|
3756
3907
|
for (const skyvernUrl of skyvernNav.visitedUrls) {
|
|
3757
3908
|
const canonSkyvernUrl = canonicalize(skyvernUrl);
|
|
3758
|
-
if (!queued.has(canonSkyvernUrl) && !visited.has(canonSkyvernUrl) && queued.size < maxScreens *
|
|
3909
|
+
if (!queued.has(canonSkyvernUrl) && !visited.has(canonSkyvernUrl) && queued.size < maxScreens * 3) {
|
|
3759
3910
|
logger.info({ url: skyvernUrl }, 'Gap 5: Skyvern-discovered URL added to ZeTa crawl queue');
|
|
3760
3911
|
queued.add(canonSkyvernUrl);
|
|
3761
3912
|
queue.push(skyvernUrl); // ZeTa will crawl this normally
|
|
@@ -3821,9 +3972,9 @@ export async function crawlProject(opts: {
|
|
|
3821
3972
|
await onProgress?.(clampedPct, `Crawling screens (${visited.size} mapped, ${queue.length} pending)`);
|
|
3822
3973
|
logger.info({ name, elements: elements.length }, 'Screen crawled');
|
|
3823
3974
|
// Checkpoint every 10 successes so a failed job can be resumed from close to where it stopped
|
|
3824
|
-
if (++checkpointSuccessCount %
|
|
3975
|
+
if (++checkpointSuccessCount % 5 === 0) {
|
|
3825
3976
|
await opts.onCheckpoint?.(
|
|
3826
|
-
Array.from(visited.
|
|
3977
|
+
Array.from(visited.entries()).filter(([_k, v]) => v !== '_pending_').map(([k]) => k),
|
|
3827
3978
|
queue.filter(u => !visited.has(canonicalize(u)))
|
|
3828
3979
|
);
|
|
3829
3980
|
}
|
|
@@ -3861,7 +4012,7 @@ export async function crawlProject(opts: {
|
|
|
3861
4012
|
// Use only successfully-saved screens (screenId present), not total discovered including errors.
|
|
3862
4013
|
const successfulScreenCount = discovered.filter(d => d.screenId).length;
|
|
3863
4014
|
const skyvernMinScreens = parseInt(process.env.CRAWL_SKYVERN_MIN_SCREENS ?? '') || 10;
|
|
3864
|
-
if (successfulScreenCount < skyvernMinScreens && opts.skyvernConfig) {
|
|
4015
|
+
if (!selective && successfulScreenCount < skyvernMinScreens && opts.skyvernConfig) {
|
|
3865
4016
|
await onProgress?.(78, 'Few screens found — using Skyvern to discover additional navigation URLs');
|
|
3866
4017
|
const currentUrl = page.url();
|
|
3867
4018
|
const skyvernNav = await skyvernNavigate(
|
|
@@ -3877,7 +4028,7 @@ export async function crawlProject(opts: {
|
|
|
3877
4028
|
logger.info({ count: skyvernUrls.length }, 'Gap 5: Skyvern found new URLs — processing with ZeTa native crawler');
|
|
3878
4029
|
// Process each URL with ZeTa's own Playwright-based extraction
|
|
3879
4030
|
for (const skyvernUrl of skyvernUrls) {
|
|
3880
|
-
if (visited.size >= maxScreens) break;
|
|
4031
|
+
if (isCrawlTimedOut() || visited.size >= maxScreens) break;
|
|
3881
4032
|
try {
|
|
3882
4033
|
await page.goto(skyvernUrl, { waitUntil: 'domcontentloaded', timeout: 20000 });
|
|
3883
4034
|
await new Promise<void>(r => setTimeout(r, 1500));
|
|
@@ -3886,7 +4037,17 @@ export async function crawlProject(opts: {
|
|
|
3886
4037
|
const shotPath = `${opts.screenshotDir ?? '/tmp'}/${screen.id}-v${screen.version}.png`;
|
|
3887
4038
|
await waitForScreenshotReady(page, rawPage ?? undefined);
|
|
3888
4039
|
await page.screenshot({ path: shotPath, fullPage: true });
|
|
3889
|
-
await
|
|
4040
|
+
const skyvernCloudUrl = await uploadToCloud(shotPath, `${opts.tenantId}/${opts.projectId}/screenshots/${path.basename(shotPath)}`).catch(() => undefined);
|
|
4041
|
+
await AppBrain.saveScreenshot(opts.tenantId, opts.projectId, screen.id, shotPath, screen.version, 'DESKTOP', skyvernCloudUrl);
|
|
4042
|
+
// GAP 7: DOM snapshot for Skyvern primary URLs
|
|
4043
|
+
const skyvernDom = await capturePageHtml(page);
|
|
4044
|
+
if (skyvernDom) {
|
|
4045
|
+
const skyvernDomPath = shotPath.replace(/\.(png|jpg|jpeg)$/i, '.dom.html');
|
|
4046
|
+
await fs.writeFile(skyvernDomPath, skyvernDom, 'utf8');
|
|
4047
|
+
const skyvernDomKey = `${opts.tenantId}/${opts.projectId}/dom-snapshots/${path.basename(skyvernDomPath)}`;
|
|
4048
|
+
const skyvernDomCloud = await uploadToCloud(skyvernDomPath, skyvernDomKey).catch(() => undefined);
|
|
4049
|
+
AppBrain.updateScreenDomSnapshot(opts.tenantId, screen.id, skyvernDomPath, skyvernDomCloud).catch(() => {});
|
|
4050
|
+
}
|
|
3890
4051
|
const [elems, links, linkMappings] = await Promise.all([
|
|
3891
4052
|
extractElements(stagehand, page, elementLLM ?? undefined, rawPage ?? undefined),
|
|
3892
4053
|
discoverLinks(stagehand, origin, page, linkLLM ?? undefined, aiSkipCount > 0, rawPage ?? undefined),
|
|
@@ -3913,7 +4074,7 @@ export async function crawlProject(opts: {
|
|
|
3913
4074
|
}
|
|
3914
4075
|
}
|
|
3915
4076
|
// Drain any newly queued URLs from Skyvern pages
|
|
3916
|
-
while (queue.length > 0 && visited.size < maxScreens) {
|
|
4077
|
+
while (queue.length > 0 && visited.size < maxScreens && !isCrawlTimedOut()) {
|
|
3917
4078
|
const href = queue.shift()!;
|
|
3918
4079
|
const canonHref = canonicalize(href);
|
|
3919
4080
|
if (visited.has(canonHref)) continue;
|
|
@@ -3926,7 +4087,17 @@ export async function crawlProject(opts: {
|
|
|
3926
4087
|
const sp = `${opts.screenshotDir ?? '/tmp'}/${sc.id}-v${sc.version}.png`;
|
|
3927
4088
|
await waitForScreenshotReady(page, rawPage ?? undefined);
|
|
3928
4089
|
await page.screenshot({ path: sp, fullPage: true });
|
|
3929
|
-
await
|
|
4090
|
+
const skyvernDrainCloudUrl = await uploadToCloud(sp, `${opts.tenantId}/${opts.projectId}/screenshots/${path.basename(sp)}`).catch(() => undefined);
|
|
4091
|
+
await AppBrain.saveScreenshot(opts.tenantId, opts.projectId, sc.id, sp, sc.version, 'DESKTOP', skyvernDrainCloudUrl);
|
|
4092
|
+
// GAP 8: DOM snapshot for Skyvern drain-queue URLs
|
|
4093
|
+
const skyvernDrainDom = await capturePageHtml(page);
|
|
4094
|
+
if (skyvernDrainDom) {
|
|
4095
|
+
const skyvernDrainDomPath = sp.replace(/\.(png|jpg|jpeg)$/i, '.dom.html');
|
|
4096
|
+
await fs.writeFile(skyvernDrainDomPath, skyvernDrainDom, 'utf8');
|
|
4097
|
+
const skyvernDrainDomKey = `${opts.tenantId}/${opts.projectId}/dom-snapshots/${path.basename(skyvernDrainDomPath)}`;
|
|
4098
|
+
const skyvernDrainDomCloud = await uploadToCloud(skyvernDrainDomPath, skyvernDrainDomKey).catch(() => undefined);
|
|
4099
|
+
AppBrain.updateScreenDomSnapshot(opts.tenantId, sc.id, skyvernDrainDomPath, skyvernDrainDomCloud).catch(() => {});
|
|
4100
|
+
}
|
|
3930
4101
|
const [elems2, linkMappings2] = await Promise.all([
|
|
3931
4102
|
extractElements(stagehand, page, elementLLM ?? undefined, rawPage ?? undefined),
|
|
3932
4103
|
extractLinkMappings(page, origin),
|
|
@@ -3986,7 +4157,8 @@ export async function crawlProject(opts: {
|
|
|
3986
4157
|
if (revisitQueue.length > 0) {
|
|
3987
4158
|
await onProgress?.(79, `Re-visiting ${revisitQueue.length} page(s) with async content…`);
|
|
3988
4159
|
logger.info({ count: revisitQueue.length }, '[P3a] Starting async content re-visit pass');
|
|
3989
|
-
for (const revisitUrl of revisitQueue.slice(0,
|
|
4160
|
+
for (const revisitUrl of revisitQueue.slice(0, 20)) { // cap at 20 re-visits; also guard global timeout
|
|
4161
|
+
if (isCrawlTimedOut()) break;
|
|
3990
4162
|
const rawScreenId = visited.get(canonicalize(revisitUrl));
|
|
3991
4163
|
// Auth-wall pages are stored as '_auth_wall_' — not a real screen ID.
|
|
3992
4164
|
// Navigate with the authenticated main page; it will redirect to the real content.
|
|
@@ -4022,7 +4194,17 @@ export async function crawlProject(opts: {
|
|
|
4022
4194
|
|
|
4023
4195
|
const shotPath2 = `${opts.screenshotDir ?? '/tmp'}/${existingScreenId}-revisit.png`;
|
|
4024
4196
|
await page.screenshot({ path: shotPath2, fullPage: true });
|
|
4025
|
-
await (
|
|
4197
|
+
const p3aCloudUrl = await uploadToCloud(shotPath2, `${opts.tenantId}/${opts.projectId}/screenshots/${path.basename(shotPath2)}`).catch(() => undefined);
|
|
4198
|
+
await (AppBrain.saveScreenshot as any)(opts.tenantId, opts.projectId, existingScreenId, shotPath2, 1, 'DESKTOP', p3aCloudUrl).catch(() => {});
|
|
4199
|
+
// GAP 10: DOM snapshot for P3a auth-wall revisit
|
|
4200
|
+
const p3aDom = await capturePageHtml(page);
|
|
4201
|
+
if (p3aDom) {
|
|
4202
|
+
const p3aDomPath = shotPath2.replace(/\.(png|jpg|jpeg)$/i, '.dom.html');
|
|
4203
|
+
await fs.writeFile(p3aDomPath, p3aDom, 'utf8');
|
|
4204
|
+
const p3aDomKey = `${opts.tenantId}/${opts.projectId}/dom-snapshots/${path.basename(p3aDomPath)}`;
|
|
4205
|
+
const p3aDomCloud = await uploadToCloud(p3aDomPath, p3aDomKey).catch(() => undefined);
|
|
4206
|
+
AppBrain.updateScreenDomSnapshot(opts.tenantId, existingScreenId, p3aDomPath, p3aDomCloud).catch(() => {});
|
|
4207
|
+
}
|
|
4026
4208
|
// Re-extract elements with the async-loaded content now visible
|
|
4027
4209
|
const revisitElements = await extractElements(stagehand, page, elementLLM ?? undefined, rawPage ?? undefined).catch(() => []);
|
|
4028
4210
|
if (revisitElements.length > 0) {
|
|
@@ -4030,12 +4212,19 @@ export async function crawlProject(opts: {
|
|
|
4030
4212
|
for (const el of revisitElements) {
|
|
4031
4213
|
await AppBrain.saveElement(opts.tenantId, opts.projectId, existingScreenId, el.meaning, el.role, el.expectedData, el.notes, undefined, true, el.boundingRect, el.ariaState, el.parentLandmark).catch(() => { });
|
|
4032
4214
|
}
|
|
4215
|
+
} else {
|
|
4216
|
+
// P3a got 0 elements — check if it's an inline session-expired page.
|
|
4217
|
+
const p3aBodyText = await page.evaluate(() => (document as any).body?.innerText ?? '').catch(() => '');
|
|
4218
|
+
if (INLINE_AUTH_WALL_RE.test(p3aBodyText)) {
|
|
4219
|
+
logger.warn({ url: revisitUrl, screenId: existingScreenId }, '[P3a] Re-visit got 0 elements — inline session-expired content, marking requiresAuth');
|
|
4220
|
+
AppBrain.updateScreenRequiresAuth(opts.tenantId, existingScreenId, true).catch(() => {});
|
|
4221
|
+
}
|
|
4033
4222
|
}
|
|
4034
4223
|
// Harvest any new links revealed by async content
|
|
4035
4224
|
const revisitLinks = await discoverLinks(stagehand, origin, page, linkLLM ?? undefined, false, rawPage ?? undefined).catch(() => []);
|
|
4036
4225
|
for (const href of revisitLinks) {
|
|
4037
4226
|
const canon = canonicalize(href);
|
|
4038
|
-
if (isUsefulNavigationUrl(href, origin) && !queued.has(canon) && !visited.has(canon) && queued.size < maxScreens *
|
|
4227
|
+
if (isUsefulNavigationUrl(href, origin) && !queued.has(canon) && !visited.has(canon) && queued.size < maxScreens * 3) {
|
|
4039
4228
|
queued.add(canon);
|
|
4040
4229
|
queue.push(href);
|
|
4041
4230
|
}
|
|
@@ -4055,6 +4244,7 @@ export async function crawlProject(opts: {
|
|
|
4055
4244
|
logger.info({ newLinks: p3bCount }, '[P3b] Extended BFS — processing links discovered during revisit pass');
|
|
4056
4245
|
await onProgress?.(82, `Extended crawl: ${p3bCount} newly discovered page(s)…`);
|
|
4057
4246
|
while (queue.length > 0 && visited.size < maxScreens && visited.size < segmentSize) {
|
|
4247
|
+
if (isCrawlTimedOut()) break;
|
|
4058
4248
|
const href = queue.shift();
|
|
4059
4249
|
if (!href) break;
|
|
4060
4250
|
const canonHref = canonicalize(href);
|
|
@@ -4072,14 +4262,23 @@ export async function crawlProject(opts: {
|
|
|
4072
4262
|
await page.screenshot({ path: sp, fullPage: true }).catch(() => {});
|
|
4073
4263
|
const cloudUrl3 = await uploadToCloud(sp, `${opts.tenantId}/${opts.projectId}/screenshots/${path.basename(sp)}`).catch(() => '');
|
|
4074
4264
|
await (AppBrain.saveScreenshot as any)(opts.tenantId, opts.projectId, screen.id, sp, screen.version, 'DESKTOP', cloudUrl3).catch(() => {});
|
|
4265
|
+
// GAP 6: DOM snapshot for P3b extended BFS
|
|
4266
|
+
const p3bDom = await capturePageHtml(page);
|
|
4267
|
+
if (p3bDom) {
|
|
4268
|
+
const p3bDomPath = sp.replace(/\.(png|jpg|jpeg)$/i, '.dom.html');
|
|
4269
|
+
await fs.writeFile(p3bDomPath, p3bDom, 'utf8');
|
|
4270
|
+
const p3bDomKey = `${opts.tenantId}/${opts.projectId}/dom-snapshots/${path.basename(p3bDomPath)}`;
|
|
4271
|
+
const p3bDomCloud = await uploadToCloud(p3bDomPath, p3bDomKey).catch(() => undefined);
|
|
4272
|
+
AppBrain.updateScreenDomSnapshot(opts.tenantId, screen.id, p3bDomPath, p3bDomCloud).catch(() => {});
|
|
4273
|
+
}
|
|
4075
4274
|
const [p3bElems, p3bLinks] = await Promise.all([
|
|
4076
4275
|
extractElements(stagehand, page, elementLLM ?? undefined, rawPage ?? undefined).catch(() => []),
|
|
4077
4276
|
discoverLinks(stagehand, origin, page, linkLLM ?? undefined, aiSkipCount > 0, rawPage ?? undefined).catch(() => []),
|
|
4078
4277
|
]);
|
|
4079
4278
|
if (p3bElems.length > 0) {
|
|
4080
4279
|
await AppBrain.clearScreenElements(opts.tenantId, opts.projectId, screen.id).catch(() => {});
|
|
4081
|
-
for (let _ei3 = 0; _ei3 < p3bElems.length; _ei3 +=
|
|
4082
|
-
const _b3 = p3bElems.slice(_ei3, _ei3 +
|
|
4280
|
+
for (let _ei3 = 0; _ei3 < p3bElems.length; _ei3 += 2) {
|
|
4281
|
+
const _b3 = p3bElems.slice(_ei3, _ei3 + 2);
|
|
4083
4282
|
await Promise.all(_b3.map((el: any) =>
|
|
4084
4283
|
AppBrain.saveElement(opts.tenantId, opts.projectId, screen.id, el.meaning, el.role, el.expectedData, el.notes, undefined, true, el.boundingRect, el.ariaState, el.parentLandmark).catch(() => {})
|
|
4085
4284
|
));
|
|
@@ -4087,7 +4286,7 @@ export async function crawlProject(opts: {
|
|
|
4087
4286
|
}
|
|
4088
4287
|
for (const link of p3bLinks) {
|
|
4089
4288
|
const c = canonicalize(link);
|
|
4090
|
-
if (isUsefulNavigationUrl(link, origin) && !queued.has(c) && !visited.has(c) && queued.size < maxScreens *
|
|
4289
|
+
if (isUsefulNavigationUrl(link, origin) && !queued.has(c) && !visited.has(c) && queued.size < maxScreens * 3) {
|
|
4091
4290
|
queued.add(c); queue.push(link);
|
|
4092
4291
|
}
|
|
4093
4292
|
}
|
|
@@ -4129,9 +4328,9 @@ export async function crawlProject(opts: {
|
|
|
4129
4328
|
|
|
4130
4329
|
// Visit each confirmed gap URL with the existing browser (depth=1, no link traversal)
|
|
4131
4330
|
const gapLoginPattern = /\/(login|signin|sign-in|auth|log-in)(\/|$|\?)/i;
|
|
4132
|
-
for (const gap of confirmedGaps.slice(0, parseInt(process.env.CRAWLER_GAP_PASS_MAX_VISITS ?? '') ||
|
|
4331
|
+
for (const gap of confirmedGaps.slice(0, parseInt(process.env.CRAWLER_GAP_PASS_MAX_VISITS ?? '') || 25)) {
|
|
4332
|
+
if (isCrawlTimedOut() || visited.size >= maxScreens) break;
|
|
4133
4333
|
const gapUrl = gap.confirmedUrl!;
|
|
4134
|
-
if (visited.size >= maxScreens) break;
|
|
4135
4334
|
try {
|
|
4136
4335
|
await page.goto(gapUrl, { waitUntil: 'domcontentloaded', timeout: 20_000 });
|
|
4137
4336
|
// Skip if session expired — don't save a login-page screenshot as the gap result
|
|
@@ -4145,7 +4344,17 @@ export async function crawlProject(opts: {
|
|
|
4145
4344
|
const gapShot = `${opts.screenshotDir ?? '/tmp'}/${gapScreen.id}-v${gapScreen.version}.png`;
|
|
4146
4345
|
await waitForScreenshotReady(page, rawPage ?? undefined);
|
|
4147
4346
|
await page.screenshot({ path: gapShot, fullPage: true });
|
|
4148
|
-
await
|
|
4347
|
+
const gapCloudUrl = await uploadToCloud(gapShot, `${opts.tenantId}/${opts.projectId}/screenshots/${path.basename(gapShot)}`).catch(() => undefined);
|
|
4348
|
+
await AppBrain.saveScreenshot(opts.tenantId, opts.projectId, gapScreen.id, gapShot, gapScreen.version, 'DESKTOP', gapCloudUrl);
|
|
4349
|
+
// GAP 9: DOM snapshot for P6 gap-pass
|
|
4350
|
+
const gapDom = await capturePageHtml(page);
|
|
4351
|
+
if (gapDom) {
|
|
4352
|
+
const gapDomPath = gapShot.replace(/\.(png|jpg|jpeg)$/i, '.dom.html');
|
|
4353
|
+
await fs.writeFile(gapDomPath, gapDom, 'utf8');
|
|
4354
|
+
const gapDomKey = `${opts.tenantId}/${opts.projectId}/dom-snapshots/${path.basename(gapDomPath)}`;
|
|
4355
|
+
const gapDomCloud = await uploadToCloud(gapDomPath, gapDomKey).catch(() => undefined);
|
|
4356
|
+
AppBrain.updateScreenDomSnapshot(opts.tenantId, gapScreen.id, gapDomPath, gapDomCloud).catch(() => {});
|
|
4357
|
+
}
|
|
4149
4358
|
const [gapElems, gapLinks, gapLinkMaps] = await Promise.all([
|
|
4150
4359
|
extractElements(stagehand, page, elementLLM ?? undefined, rawPage ?? undefined).catch(() => []),
|
|
4151
4360
|
discoverLinks(stagehand, origin, page, linkLLM ?? undefined, false, rawPage ?? undefined).catch(() => []),
|
|
@@ -4154,6 +4363,13 @@ export async function crawlProject(opts: {
|
|
|
4154
4363
|
for (const el of gapElems) {
|
|
4155
4364
|
await AppBrain.saveElement(opts.tenantId, opts.projectId, gapScreen.id, el.meaning, el.role, el.expectedData, el.notes, undefined, true, el.boundingRect, el.ariaState, el.parentLandmark).catch(() => { });
|
|
4156
4365
|
}
|
|
4366
|
+
if (gapElems.length === 0) {
|
|
4367
|
+
const gapBodyText = await page.evaluate(() => (document as any).body?.innerText ?? '').catch(() => '');
|
|
4368
|
+
if (INLINE_AUTH_WALL_RE.test(gapBodyText)) {
|
|
4369
|
+
logger.warn({ url: gapUrl, screenId: gapScreen.id }, '[P6] Gap URL got 0 elements — inline session-expired content, marking requiresAuth');
|
|
4370
|
+
AppBrain.updateScreenRequiresAuth(opts.tenantId, gapScreen.id, true).catch(() => {});
|
|
4371
|
+
}
|
|
4372
|
+
}
|
|
4157
4373
|
visited.set(canonicalize(gapUrl), gapScreen.id);
|
|
4158
4374
|
discovered.push({ url: gapUrl, screenId: gapScreen.id, elements: gapElems.length, version: gapScreen.version });
|
|
4159
4375
|
for (const lm of gapLinkMaps) {
|
|
@@ -4162,7 +4378,7 @@ export async function crawlProject(opts: {
|
|
|
4162
4378
|
// Enqueue gap page's links for further crawl (within maxScreens budget)
|
|
4163
4379
|
for (const href of gapLinks) {
|
|
4164
4380
|
const canon = canonicalize(href);
|
|
4165
|
-
if (isUsefulNavigationUrl(href, origin) && !queued.has(canon) && !visited.has(canon) && queued.size < maxScreens *
|
|
4381
|
+
if (isUsefulNavigationUrl(href, origin) && !queued.has(canon) && !visited.has(canon) && queued.size < maxScreens * 3) {
|
|
4166
4382
|
queued.add(canon); queue.push(href);
|
|
4167
4383
|
}
|
|
4168
4384
|
}
|
|
@@ -4387,6 +4603,7 @@ export async function crawlProject(opts: {
|
|
|
4387
4603
|
const mobilePage = await mobileCtx.newPage();
|
|
4388
4604
|
|
|
4389
4605
|
for (const disc of discovered) {
|
|
4606
|
+
if (isCrawlTimedOut()) break;
|
|
4390
4607
|
if (!disc.screenId) continue;
|
|
4391
4608
|
try {
|
|
4392
4609
|
await mobilePage.goto(disc.url, { waitUntil: 'domcontentloaded', timeout: 20_000 });
|
|
@@ -4537,8 +4754,17 @@ export async function crawlProject(opts: {
|
|
|
4537
4754
|
|
|
4538
4755
|
await onProgress?.(98, 'Finalizing');
|
|
4539
4756
|
// Remaining queue = URLs discovered but not yet crawled due to segment limit
|
|
4757
|
+
const wasTimeout = isCrawlTimedOut();
|
|
4540
4758
|
const pendingUrls = queue.filter((u) => !visited.has(canonicalize(u)));
|
|
4759
|
+
// All fully-processed URLs (exclude '_pending_' entries interrupted by timeout/abort)
|
|
4760
|
+
const visitedUrls = Array.from(visited.entries())
|
|
4761
|
+
.filter(([_k, v]) => v !== '_pending_')
|
|
4762
|
+
.map(([k]) => k);
|
|
4541
4763
|
const hasMoreSegments = pendingUrls.length > 0;
|
|
4764
|
+
if (wasTimeout && pendingUrls.length > 0) {
|
|
4765
|
+
const elapsedMin = Math.round((Date.now() - crawlLoopStart) / 60_000);
|
|
4766
|
+
crawlWarnings.push(`global-timeout: crawl stopped after ${elapsedMin} minutes with ${pendingUrls.length} URLs remaining`);
|
|
4767
|
+
}
|
|
4542
4768
|
const screensSucceeded = discovered.filter(d => !!d.screenId).length;
|
|
4543
4769
|
const screensErrored = discovered.filter(d => !!d.error && !d.screenId).length;
|
|
4544
4770
|
|
|
@@ -4562,6 +4788,8 @@ export async function crawlProject(opts: {
|
|
|
4562
4788
|
apiEndpoints: capturedApiEndpoints,
|
|
4563
4789
|
performanceMetrics,
|
|
4564
4790
|
pendingUrls, // feed into next segment's resumeUrls
|
|
4791
|
+
visitedUrls, // all fully-processed URLs for skipUrls in continuation
|
|
4792
|
+
wasTimeout, // true when global timeout triggered the BFS break
|
|
4565
4793
|
segmentIndex: segIdx, // which segment this was
|
|
4566
4794
|
hasMoreSegments,
|
|
4567
4795
|
domHashes: Array.from(seenDomHashes), // carry forward to next segment to avoid re-screenshotting
|
|
@@ -4621,7 +4849,7 @@ async function extractElements(stagehand: Stagehand, page: any, llmCfg?: { provi
|
|
|
4621
4849
|
if (!iframeUrl || iframeUrl === 'about:blank' || iframeUrl.startsWith('javascript:')) continue;
|
|
4622
4850
|
const frameEls = await domFallbackElements(frame).catch(() => []);
|
|
4623
4851
|
// Tag iframe elements with source context
|
|
4624
|
-
for (const el of frameEls.slice(0,
|
|
4852
|
+
for (const el of frameEls.slice(0, 30)) {
|
|
4625
4853
|
iframeElements.push({ ...el, notes: el.notes ? `${el.notes} [in iframe]` : '[in iframe]' });
|
|
4626
4854
|
}
|
|
4627
4855
|
}
|
|
@@ -4645,7 +4873,7 @@ async function extractElements(stagehand: Stagehand, page: any, llmCfg?: { provi
|
|
|
4645
4873
|
seen.add(label);
|
|
4646
4874
|
out.push({ meaning: label, role, notes: '[dynamic/revealed]' });
|
|
4647
4875
|
});
|
|
4648
|
-
return out.slice(0,
|
|
4876
|
+
return out.slice(0, 30);
|
|
4649
4877
|
}).catch(() => []);
|
|
4650
4878
|
} catch { dynamicElements = []; }
|
|
4651
4879
|
}
|
|
@@ -4706,7 +4934,7 @@ async function extractElements(stagehand: Stagehand, page: any, llmCfg?: { provi
|
|
|
4706
4934
|
if (!extracted) return domElements;
|
|
4707
4935
|
// Apply noise filter to AI results — AI doesn't always follow skip instructions
|
|
4708
4936
|
const filtered = (extracted as any).elements.filter((e: any) => !ELEMENT_NOISE.test(e.meaning ?? ''));
|
|
4709
|
-
return filtered.slice(0,
|
|
4937
|
+
return filtered.slice(0, 40);
|
|
4710
4938
|
} catch {
|
|
4711
4939
|
return domElements;
|
|
4712
4940
|
}
|
|
@@ -4737,9 +4965,9 @@ async function discoverLinks(stagehand: Stagehand, origin: string, page: any, ll
|
|
|
4737
4965
|
|
|
4738
4966
|
const allDomLinks = Array.from(new Set([...domLinks, ...interactiveLinks]));
|
|
4739
4967
|
|
|
4740
|
-
if (allDomLinks.length >=
|
|
4968
|
+
if (allDomLinks.length >= 50 || skipAi) return allDomLinks;
|
|
4741
4969
|
|
|
4742
|
-
// AI complement: DOM found fewer than
|
|
4970
|
+
// AI complement: DOM found fewer than 50 unique links — SPA likely has JS-router pages not in <a href>.
|
|
4743
4971
|
// Always merge DOM + AI so we never lose already-discovered links.
|
|
4744
4972
|
// 12s timeout: allows 1 OpenAI retry (4.8s delay) but bails before the 2nd retry wastes more time.
|
|
4745
4973
|
try {
|
|
@@ -5151,7 +5379,7 @@ async function contentFallbackElements(page: any): Promise<Array<{ meaning: stri
|
|
|
5151
5379
|
const mainContent = document.querySelector('main, [role="main"], article') ?? document.body;
|
|
5152
5380
|
let listItemCount = 0;
|
|
5153
5381
|
mainContent.querySelectorAll('li').forEach((li: any) => {
|
|
5154
|
-
if (listItemCount >=
|
|
5382
|
+
if (listItemCount >= 50) return;
|
|
5155
5383
|
if (!isVisible(li)) return;
|
|
5156
5384
|
const text = li.textContent?.replace(/\s+/g, ' ').trim().slice(0, 150);
|
|
5157
5385
|
if (text && text.length > 3 && text.length < 200) { addUniq({ meaning: text, role: 'list-item' }, li); listItemCount++; }
|
|
@@ -5355,11 +5583,11 @@ async function contentFallbackElements(page: any): Promise<Array<{ meaning: stri
|
|
|
5355
5583
|
if (url) { addUniq({ meaning: `Lazy BG image: ${url.split('/').pop()?.split('?')[0] ?? url}`, role: 'bg-image', notes: JSON.stringify({ url, pattern: 'data-bg' }) }); bgCount++; }
|
|
5356
5584
|
});
|
|
5357
5585
|
|
|
5358
|
-
// 12. Content links outside nav/header/footer (capped at
|
|
5586
|
+
// 12. Content links outside nav/header/footer (capped at 40)
|
|
5359
5587
|
const navTags = new Set(['nav', 'header', 'footer']);
|
|
5360
5588
|
let contentLinkCount = 0;
|
|
5361
5589
|
document.querySelectorAll('a[href]').forEach((a: any) => {
|
|
5362
|
-
if (contentLinkCount >=
|
|
5590
|
+
if (contentLinkCount >= 40) return;
|
|
5363
5591
|
if (!isVisible(a)) return;
|
|
5364
5592
|
let p = a.parentElement;
|
|
5365
5593
|
while (p) { if (navTags.has(p.tagName?.toLowerCase())) return; p = p.parentElement; }
|
|
@@ -5848,7 +6076,7 @@ async function interactAndCollectLinks(page: any, origin: string): Promise<strin
|
|
|
5848
6076
|
document.querySelectorAll('[role="tab"]').length
|
|
5849
6077
|
).catch(() => 0);
|
|
5850
6078
|
|
|
5851
|
-
for (let i = 0; i < Math.min(tabCount,
|
|
6079
|
+
for (let i = 0; i < Math.min(tabCount, 30); i++) {
|
|
5852
6080
|
try {
|
|
5853
6081
|
const clicked = await page.evaluate((idx: number) => {
|
|
5854
6082
|
const tabs = document.querySelectorAll<HTMLElement>('[role="tab"]');
|
|
@@ -5895,7 +6123,7 @@ async function interactAndCollectLinks(page: any, origin: string): Promise<strin
|
|
|
5895
6123
|
document.querySelectorAll('details:not([open]) > summary, [data-state="closed"] > button, [aria-expanded="false"][class*="accordion" i]').length
|
|
5896
6124
|
).catch(() => 0);
|
|
5897
6125
|
|
|
5898
|
-
for (let i = 0; i < Math.min(accordionCount,
|
|
6126
|
+
for (let i = 0; i < Math.min(accordionCount, 20); i++) {
|
|
5899
6127
|
try {
|
|
5900
6128
|
await page.evaluate((idx: number) => {
|
|
5901
6129
|
const items = document.querySelectorAll<HTMLElement>(
|
|
@@ -5930,7 +6158,7 @@ async function expandNavMenus(page: any): Promise<boolean> {
|
|
|
5930
6158
|
while (p) {
|
|
5931
6159
|
const tag = p.tagName?.toLowerCase();
|
|
5932
6160
|
const role = p.getAttribute('role') ?? '';
|
|
5933
|
-
if (['
|
|
6161
|
+
if (['table', 'select'].includes(tag ?? '') ||
|
|
5934
6162
|
['dialog', 'alertdialog', 'combobox', 'listbox', 'grid', 'treegrid'].includes(role)) return true;
|
|
5935
6163
|
p = p.parentElement;
|
|
5936
6164
|
}
|
|
@@ -5963,7 +6191,7 @@ async function expandNavMenus(page: any): Promise<boolean> {
|
|
|
5963
6191
|
'[role="navigation"] > ul > li, [role="menubar"] > [role="menuitem"], ' +
|
|
5964
6192
|
'[class*="nav"] > ul > li, [class*="menu"] > li'
|
|
5965
6193
|
).catch(() => [] as any[]);
|
|
5966
|
-
for (const h of (navHandles as any[]).slice(0,
|
|
6194
|
+
for (const h of (navHandles as any[]).slice(0, 40)) {
|
|
5967
6195
|
try { await h.hover({ force: true, timeout: 600 }); await page.waitForTimeout(280); } catch { /* skip invisible */ }
|
|
5968
6196
|
}
|
|
5969
6197
|
if ((navHandles as any[]).length > 0) {
|
|
@@ -6009,6 +6237,11 @@ async function domFallbackLinks(page: any, origin: string, rawPage?: any): Promi
|
|
|
6009
6237
|
root.querySelectorAll('[data-href]').forEach((el) => {
|
|
6010
6238
|
add(el.getAttribute('data-href') || '', el.textContent || '');
|
|
6011
6239
|
});
|
|
6240
|
+
// Form[action] GET forms: JSP/MVC apps navigate via form submissions (not anchor links)
|
|
6241
|
+
root.querySelectorAll('form[action]').forEach((form) => {
|
|
6242
|
+
const method = (form.getAttribute('method') || 'get').toLowerCase();
|
|
6243
|
+
if (method !== 'post') add(form.getAttribute('action') || '', form.getAttribute('aria-label') || '');
|
|
6244
|
+
});
|
|
6012
6245
|
root.querySelectorAll('*').forEach((el) => {
|
|
6013
6246
|
try {
|
|
6014
6247
|
if (el.shadowRoot && !rootSeen.has(el.shadowRoot)) {
|
|
@@ -6470,6 +6703,30 @@ async function captureAuthDiscoveryScreen(
|
|
|
6470
6703
|
if (existsSyncFs(shotPath)) {
|
|
6471
6704
|
await AppBrain.saveScreenshot(opts.tenantId, opts.projectId, screen.id, shotPath, screen.version, 'DESKTOP', authCloudUrl).catch(() => { });
|
|
6472
6705
|
}
|
|
6706
|
+
// GAP 11: DOM snapshot for auth-discovery phase
|
|
6707
|
+
const authDomContent = await capturePageHtml(page);
|
|
6708
|
+
if (authDomContent && opts.screenshotDir) {
|
|
6709
|
+
const authDomPath = shotPath.replace(/\.(png|jpg|jpeg)$/i, '.dom.html');
|
|
6710
|
+
await fs.writeFile(authDomPath, authDomContent, 'utf8');
|
|
6711
|
+
let authDomCloudUrl: string | undefined;
|
|
6712
|
+
if (opts.storageConfig) {
|
|
6713
|
+
try {
|
|
6714
|
+
const { uploadScreenshotToTenantStorage } = await import('@detiq/app-brain');
|
|
6715
|
+
authDomCloudUrl = await uploadScreenshotToTenantStorage(opts.storageConfig as any, authDomPath, `${opts.tenantId}/${opts.projectId}/dom-snapshots/${path.basename(authDomPath)}`);
|
|
6716
|
+
} catch { /* non-fatal */ }
|
|
6717
|
+
}
|
|
6718
|
+
await AppBrain.updateScreenDomSnapshot(opts.tenantId, screen.id, authDomPath, authDomCloudUrl).catch(() => {});
|
|
6719
|
+
}
|
|
6720
|
+
// Extract and save interactive elements (inputs, buttons, links) for this auth-discovery screen.
|
|
6721
|
+
// Without this, login pages and auth flows always show 0 elements in Crawl Captures.
|
|
6722
|
+
const authDiscoveryElements = await readScreen(page).catch(() => [] as Awaited<ReturnType<typeof readScreen>>);
|
|
6723
|
+
if (authDiscoveryElements.length > 0) {
|
|
6724
|
+
await AppBrain.clearScreenElements(opts.tenantId, opts.projectId, screen.id).catch(() => {});
|
|
6725
|
+
for (const el of authDiscoveryElements) {
|
|
6726
|
+
await AppBrain.saveElement(opts.tenantId, opts.projectId, screen.id, el.label, el.role, el.expectedData, undefined, undefined, true, undefined, undefined, undefined).catch(() => {});
|
|
6727
|
+
}
|
|
6728
|
+
logger.info({ screenId: screen.id, elements: authDiscoveryElements.length }, '[crawler] auth-discovery: saved elements');
|
|
6729
|
+
}
|
|
6473
6730
|
await AppBrain.saveCrawlEvidence(opts.tenantId, opts.projectId, {
|
|
6474
6731
|
screenId: screen.id,
|
|
6475
6732
|
jobId: opts.jobId,
|