@detiq/crawler 1.1.19 → 1.1.20

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/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 html = await page.content();
263
- if (typeof html === 'string' && html.trim()) return html;
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
- const html = await page.evaluate(() => {
271
- const type = document.doctype;
272
- const doctype = type
273
- ? '<!DOCTYPE ' + type.name +
274
- (type.publicId ? ' PUBLIC "' + type.publicId + '"' : '') +
275
- (type.systemId ? ' "' + type.systemId + '"' : '') +
276
- '>'
277
- : '<!DOCTYPE html>';
278
- return doctype + '\n' + (document.documentElement?.outerHTML ?? document.body?.outerHTML ?? '');
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
- return typeof html === 'string' && html.trim() ? html : null;
314
+ if (typeof html !== 'string' || !html.trim()) html = null;
281
315
  } catch (err: any) {
282
- logger.warn({ err: String(err?.message ?? err) }, '[crawler] DOM snapshot capture failed');
283
- return null;
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, 30);
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.slice(0, 30)) {
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 AppBrain.saveScreenshot(opts.tenantId, opts.projectId, pubScreen.id, shotPath, pubScreen.version, 'DESKTOP').catch(() => { });
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 += 4) {
2126
- const _batch = allElements.slice(_ei, _ei + 4);
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 AppBrain.saveScreenshot(opts.tenantId, opts.projectId, modalScreen.id, modalShotPath, modalScreen.version, 'DESKTOP').catch(() => { });
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
- AppBrain.updateScreenDomSnapshot(opts.tenantId, screen.id, domPath).catch(() => { });
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: 4 concurrent DB writes instead of serial round-trips
3591
- for (let _ei = 0; _ei < elements.length; _ei += 4) {
3592
- const _batch = elements.slice(_ei, _ei + 4);
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 * 2
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 * 2) {
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 * 2) {
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 * 2) {
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 * 2) {
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 % 10 === 0) {
3975
+ if (++checkpointSuccessCount % 5 === 0) {
3825
3976
  await opts.onCheckpoint?.(
3826
- Array.from(visited.keys()),
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 AppBrain.saveScreenshot(opts.tenantId, opts.projectId, screen.id, shotPath, screen.version, 'DESKTOP');
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 AppBrain.saveScreenshot(opts.tenantId, opts.projectId, sc.id, sp, sc.version, 'DESKTOP');
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, 10)) { // cap at 10 re-visits
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 (AppBrain.saveScreenshot as any)(opts.tenantId, opts.projectId, existingScreenId, shotPath2, 1, 'DESKTOP', undefined).catch(() => {});
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 * 2) {
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 += 4) {
4082
- const _b3 = p3bElems.slice(_ei3, _ei3 + 4);
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 * 2) {
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 ?? '') || 8)) {
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 AppBrain.saveScreenshot(opts.tenantId, opts.projectId, gapScreen.id, gapShot, gapScreen.version, 'DESKTOP');
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 * 2) {
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, 10)) {
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, 10);
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, 20);
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 >= 30 || skipAi) return allDomLinks;
4968
+ if (allDomLinks.length >= 50 || skipAi) return allDomLinks;
4741
4969
 
4742
- // AI complement: DOM found fewer than 30 unique links — SPA likely has JS-router pages not in <a href>.
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 >= 30) return;
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 20)
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 >= 20) return;
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, 15); i++) {
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, 10); i++) {
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 (['form', 'table', 'select'].includes(tag ?? '') ||
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, 20)) {
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,20 @@ 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
+ }
6473
6720
  await AppBrain.saveCrawlEvidence(opts.tenantId, opts.projectId, {
6474
6721
  screenId: screen.id,
6475
6722
  jobId: opts.jobId,