@detiq/crawler 1.1.18 → 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/dist/crawler.js CHANGED
@@ -196,25 +196,53 @@ function normalizeAuthUrl(url) {
196
196
  }
197
197
  }
198
198
  async function capturePageHtml(page) {
199
+ let html = null;
199
200
  try {
200
201
  if (typeof page.content === "function") {
201
- const html = await page.content();
202
- if (typeof html === "string" && html.trim()) return html;
202
+ const raw = await page.content();
203
+ if (typeof raw === "string" && raw.trim()) html = raw;
203
204
  }
204
205
  } catch (err) {
205
206
  logger.warn({ err: String(err?.message ?? err) }, "[crawler] page.content() failed; trying DOM evaluate fallback");
206
207
  }
208
+ if (!html) {
209
+ try {
210
+ html = await page.evaluate(() => {
211
+ const type = document.doctype;
212
+ const doctype = type ? "<!DOCTYPE " + type.name + (type.publicId ? ' PUBLIC "' + type.publicId + '"' : "") + (type.systemId ? ' "' + type.systemId + '"' : "") + ">" : "<!DOCTYPE html>";
213
+ return doctype + "\n" + (document.documentElement?.outerHTML ?? document.body?.outerHTML ?? "");
214
+ });
215
+ if (typeof html !== "string" || !html.trim()) html = null;
216
+ } catch (err) {
217
+ logger.warn({ err: String(err?.message ?? err) }, "[crawler] DOM snapshot capture failed");
218
+ return null;
219
+ }
220
+ }
221
+ if (!html) return null;
207
222
  try {
208
- const html = await page.evaluate(() => {
209
- const type = document.doctype;
210
- const doctype = type ? "<!DOCTYPE " + type.name + (type.publicId ? ' PUBLIC "' + type.publicId + '"' : "") + (type.systemId ? ' "' + type.systemId + '"' : "") + ">" : "<!DOCTYPE html>";
211
- return doctype + "\n" + (document.documentElement?.outerHTML ?? document.body?.outerHTML ?? "");
223
+ html = await page.evaluate(() => {
224
+ document.querySelectorAll("script").forEach((s) => s.remove());
225
+ for (const sheet of Array.from(document.styleSheets)) {
226
+ try {
227
+ const cssText = Array.from(sheet.cssRules).map((r) => r.cssText).join("\n");
228
+ const style = document.createElement("style");
229
+ style.textContent = cssText;
230
+ const owner = sheet.ownerNode;
231
+ owner?.replaceWith(style);
232
+ } catch {
233
+ }
234
+ }
235
+ const doctype = (() => {
236
+ const dt = document.doctype;
237
+ return dt ? `<!DOCTYPE ${dt.name}>` : "<!DOCTYPE html>";
238
+ })();
239
+ return doctype + "\n" + (document.documentElement?.outerHTML ?? "");
212
240
  });
213
- return typeof html === "string" && html.trim() ? html : null;
241
+ if (typeof html !== "string" || !html.trim()) html = null;
214
242
  } catch (err) {
215
- logger.warn({ err: String(err?.message ?? err) }, "[crawler] DOM snapshot capture failed");
216
- return null;
243
+ logger.warn({ err: String(err?.message ?? err) }, "[crawler] CSS inlining failed; using raw HTML");
217
244
  }
245
+ return html;
218
246
  }
219
247
  async function launchChromeCDP(execPath, extraArgs, proxy) {
220
248
  const port = 9200 + Math.floor(Math.random() * 700);
@@ -780,6 +808,14 @@ async function crawlProject(opts, onProgress) {
780
808
  const cloudUrl = await uploadToCloud(popupShotPath, `${opts.tenantId}/${opts.projectId}/screenshots/${path.basename(popupShotPath)}`).catch(() => void 0);
781
809
  await AppBrain.saveScreenshot(opts.tenantId, opts.projectId, popupScreen.id, popupShotPath, popupScreen.version, "DESKTOP", cloudUrl).catch(() => {
782
810
  });
811
+ const popupDom = await capturePageHtml(popup);
812
+ if (popupDom) {
813
+ const popupDomPath = popupShotPath.replace(/\.(png|jpg|jpeg)$/i, ".dom.html");
814
+ await fs.writeFile(popupDomPath, popupDom, "utf8");
815
+ const popupDomKey = `${opts.tenantId}/${opts.projectId}/dom-snapshots/${path.basename(popupDomPath)}`;
816
+ AppBrain.updateScreenDomSnapshot(opts.tenantId, popupScreen.id, popupDomPath, await uploadToCloud(popupDomPath, popupDomKey).catch(() => void 0)).catch(() => {
817
+ });
818
+ }
783
819
  const popupEls = await domFallbackElements(popup, popup).catch(() => []);
784
820
  for (const el of popupEls) {
785
821
  AppBrain.saveElement(opts.tenantId, opts.projectId, popupScreen.id, el.meaning, el.role, el.expectedData, el.notes, void 0, true, el.boundingRect, el.ariaState, el.parentLandmark).catch(() => {
@@ -931,7 +967,7 @@ async function crawlProject(opts, onProgress) {
931
967
  } catch {
932
968
  return "";
933
969
  }
934
- }).filter((h, i, arr) => h && arr.indexOf(h) === i).slice(0, 30);
970
+ }).filter((h, i, arr) => h && arr.indexOf(h) === i).slice(0, 50);
935
971
  }, _appOrigin).catch(() => []);
936
972
  for (const link of publicNavLinks) {
937
973
  if (_norm(link) === _startNorm) continue;
@@ -968,6 +1004,14 @@ async function crawlProject(opts, onProgress) {
968
1004
  const cloudUrl = await uploadToCloud(shotPath, `${opts.tenantId}/${opts.projectId}/screenshots/${path.basename(shotPath)}`).catch(() => void 0);
969
1005
  await AppBrain.saveScreenshot(opts.tenantId, opts.projectId, screen.id, shotPath, screen.version, "DESKTOP", cloudUrl).catch(() => {
970
1006
  });
1007
+ const pubDom = await capturePageHtml(page);
1008
+ if (pubDom) {
1009
+ const pubDomPath = shotPath.replace(/\.(png|jpg|jpeg)$/i, ".dom.html");
1010
+ await fs.writeFile(pubDomPath, pubDom, "utf8");
1011
+ const pubDomKey = `${opts.tenantId}/${opts.projectId}/dom-snapshots/${path.basename(pubDomPath)}`;
1012
+ AppBrain.updateScreenDomSnapshot(opts.tenantId, screen.id, pubDomPath, await uploadToCloud(pubDomPath, pubDomKey).catch(() => void 0)).catch(() => {
1013
+ });
1014
+ }
971
1015
  await AppBrain.clearScreenElements(opts.tenantId, opts.projectId, screen.id).catch(() => {
972
1016
  });
973
1017
  for (const el of preAuthElements) {
@@ -1369,6 +1413,8 @@ async function crawlProject(opts, onProgress) {
1369
1413
  let maxSeenTotal = 0;
1370
1414
  let lastEmittedPct = 20;
1371
1415
  const crawlLoopStart = Date.now();
1416
+ const CRAWL_GLOBAL_TIMEOUT_MS = parseInt(process.env.CRAWL_GLOBAL_TIMEOUT_MS ?? "") || 2 * 60 * 60 * 1e3;
1417
+ const isCrawlTimedOut = () => Date.now() - crawlLoopStart >= CRAWL_GLOBAL_TIMEOUT_MS;
1372
1418
  const startUrl = page.url();
1373
1419
  const resumeUrls = opts.resumeUrls ?? [];
1374
1420
  const gqlIntrospectionPromise = (async () => {
@@ -1398,6 +1444,10 @@ async function crawlProject(opts, onProgress) {
1398
1444
  ];
1399
1445
  const queued = new Set(seedUrls.map(canonicalize));
1400
1446
  const queue = new CrawlFrontier(opts.traversalStrategy ?? "BFS", seedUrls, opts.bestFirstKeywords, opts.urlFilter);
1447
+ if (selective) {
1448
+ queue.push = () => {
1449
+ };
1450
+ }
1401
1451
  const deduplicator = new ContentDeduplicator(parseFloat(process.env.CRAWLER_DEDUP_THRESHOLD ?? "") || 0.88);
1402
1452
  const deadLinkTracker = new DeadLinkTracker();
1403
1453
  const redirectRegistry = new RedirectRegistry();
@@ -1479,7 +1529,8 @@ async function crawlProject(opts, onProgress) {
1479
1529
  logger.info({ count: g11SitemapUrls.length }, "[crawler] G11: sitemap discovered public URL candidates");
1480
1530
  const sitemapOrigin = new URL(opts.appUrl).origin;
1481
1531
  let sitemapCaptured = 0;
1482
- for (const sUrl of g11SitemapUrls.slice(0, 30)) {
1532
+ for (const sUrl of g11SitemapUrls) {
1533
+ if (isCrawlTimedOut()) break;
1483
1534
  try {
1484
1535
  const u = new URL(sUrl);
1485
1536
  if (u.origin !== sitemapOrigin) continue;
@@ -1506,8 +1557,18 @@ async function crawlProject(opts, onProgress) {
1506
1557
  });
1507
1558
  const pubScreen = await AppBrain.saveScreen(opts.tenantId, opts.projectId, pageName, sUrl, opts.jobId, "SITEMAP").catch(() => null);
1508
1559
  if (pubScreen) {
1509
- await AppBrain.saveScreenshot(opts.tenantId, opts.projectId, pubScreen.id, shotPath, pubScreen.version, "DESKTOP").catch(() => {
1560
+ const sitemapCloudUrl = await uploadToCloud(shotPath, `${opts.tenantId}/${opts.projectId}/screenshots/${path.basename(shotPath)}`).catch(() => void 0);
1561
+ await AppBrain.saveScreenshot(opts.tenantId, opts.projectId, pubScreen.id, shotPath, pubScreen.version, "DESKTOP", sitemapCloudUrl).catch(() => {
1510
1562
  });
1563
+ const sitemapDom = await capturePageHtml(page);
1564
+ if (sitemapDom) {
1565
+ const sitemapDomPath = shotPath.replace(/\.(png|jpg|jpeg)$/i, ".dom.html");
1566
+ await fs.writeFile(sitemapDomPath, sitemapDom, "utf8");
1567
+ const sitemapDomKey = `${opts.tenantId}/${opts.projectId}/dom-snapshots/${path.basename(sitemapDomPath)}`;
1568
+ const sitemapDomCloud = await uploadToCloud(sitemapDomPath, sitemapDomKey).catch(() => void 0);
1569
+ AppBrain.updateScreenDomSnapshot(opts.tenantId, pubScreen.id, sitemapDomPath, sitemapDomCloud).catch(() => {
1570
+ });
1571
+ }
1511
1572
  logger.info({ url: sUrl, name: pageName }, "[crawler] G11: captured public sitemap page");
1512
1573
  }
1513
1574
  sitemapCaptured++;
@@ -1558,6 +1619,7 @@ async function crawlProject(opts, onProgress) {
1558
1619
  async function runLiteWorker(workerPage) {
1559
1620
  while (queue.length > 0 && visited.size < maxScreens && visited.size < segmentSize) {
1560
1621
  if (opts.abortSignal?.aborted) break;
1622
+ if (isCrawlTimedOut()) break;
1561
1623
  if (++totalAttemptsThisSegment > maxAttempts) break;
1562
1624
  const url = queue.shift();
1563
1625
  if (!url) break;
@@ -1619,6 +1681,16 @@ async function crawlProject(opts, onProgress) {
1619
1681
  const name = pathParts.map((p) => p.charAt(0).toUpperCase() + p.slice(1).replace(/[-_]/g, " ")).join(" \u2014 ") || "Home";
1620
1682
  const screen = await AppBrain.saveScreen(opts.tenantId, opts.projectId, name, url, opts.jobId, discoveryMethodFor(url));
1621
1683
  visited.set(canonUrl, screen.id);
1684
+ 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;
1685
+ const lwBodyText = await workerPage.evaluate(() => document.body?.innerText ?? "").catch(() => "");
1686
+ if (INLINE_AUTH_WALL_RE.test(lwBodyText)) {
1687
+ logger.warn({ url, screenId: screen.id }, "[lite-worker] Inline session-expired content detected \u2014 marking requiresAuth");
1688
+ AppBrain.updateScreenRequiresAuth(opts.tenantId, screen.id, true).catch(() => {
1689
+ });
1690
+ visited.set(canonUrl, "_auth_wall_");
1691
+ if (!revisitQueue.includes(url)) revisitQueue.push(url);
1692
+ continue;
1693
+ }
1622
1694
  const shotPath = path.join(opts.screenshotDir ?? "/tmp", `${screen.id}-desktop-v${screen.version}.png`);
1623
1695
  let liteElements = await domFallbackElements(workerPage).catch(() => []);
1624
1696
  if (liteElements.length === 0) {
@@ -1643,6 +1715,15 @@ async function crawlProject(opts, onProgress) {
1643
1715
  }).catch(() => {
1644
1716
  })
1645
1717
  ]);
1718
+ const lwDom = await capturePageHtml(workerPage);
1719
+ if (lwDom) {
1720
+ const lwDomPath = shotPath.replace(/\.(png|jpg|jpeg)$/i, ".dom.html");
1721
+ await fs.writeFile(lwDomPath, lwDom, "utf8");
1722
+ const lwDomKey = `${opts.tenantId}/${opts.projectId}/dom-snapshots/${path.basename(lwDomPath)}`;
1723
+ const lwDomCloud = await uploadToCloud(lwDomPath, lwDomKey).catch(() => void 0);
1724
+ AppBrain.updateScreenDomSnapshot(opts.tenantId, screen.id, lwDomPath, lwDomCloud).catch(() => {
1725
+ });
1726
+ }
1646
1727
  const _navEls = await workerPage.evaluate(() => {
1647
1728
  if (typeof globalThis.__name === "undefined") globalThis.__name = (fn) => fn;
1648
1729
  const seen = /* @__PURE__ */ new Set();
@@ -1762,8 +1843,8 @@ async function crawlProject(opts, onProgress) {
1762
1843
  if (allElements.length > 0) {
1763
1844
  await AppBrain.clearScreenElements(opts.tenantId, opts.projectId, screen.id).catch(() => {
1764
1845
  });
1765
- for (let _ei = 0; _ei < allElements.length; _ei += 4) {
1766
- const _batch = allElements.slice(_ei, _ei + 4);
1846
+ for (let _ei = 0; _ei < allElements.length; _ei += 2) {
1847
+ const _batch = allElements.slice(_ei, _ei + 2);
1767
1848
  await Promise.all(_batch.map(
1768
1849
  (el) => AppBrain.saveElement(opts.tenantId, opts.projectId, screen.id, el.meaning, el.role, el.expectedData, el.notes, void 0, true, el.boundingRect, el.ariaState, el.parentLandmark).catch(() => {
1769
1850
  })
@@ -1875,6 +1956,13 @@ async function crawlProject(opts, onProgress) {
1875
1956
  logger.info("Abort signal received \u2014 stopping crawl");
1876
1957
  throw new Error("CRAWL_ABORTED");
1877
1958
  }
1959
+ if (isCrawlTimedOut()) {
1960
+ const elapsedMin = Math.round((Date.now() - crawlLoopStart) / 6e4);
1961
+ logger.warn({ elapsedMin, visited: visited.size, queued: queue.length }, "[crawler] Global timeout reached \u2014 stopping BFS to prevent runaway crawl");
1962
+ await onProgress?.(89, `Crawl timeout after ${elapsedMin}min \u2014 ${visited.size} pages captured`).catch(() => {
1963
+ });
1964
+ break;
1965
+ }
1878
1966
  if (opts.autoThrottle && navCount > 0) {
1879
1967
  const delayMs = Math.min(5e3, Math.max(0, avgNavMs * 0.5));
1880
1968
  if (delayMs > 100) await page.waitForTimeout(delayMs).catch(() => {
@@ -2069,8 +2157,18 @@ async function crawlProject(opts, onProgress) {
2069
2157
  }).catch(() => "Modal");
2070
2158
  const modalScreen = await AppBrain.saveScreen(opts.tenantId, opts.projectId, modalName, url, opts.jobId, discoveryMethodFor(url)).catch(() => null);
2071
2159
  if (modalScreen) {
2072
- await AppBrain.saveScreenshot(opts.tenantId, opts.projectId, modalScreen.id, modalShotPath, modalScreen.version, "DESKTOP").catch(() => {
2160
+ const modalCloudUrl = await uploadToCloud(modalShotPath, `${opts.tenantId}/${opts.projectId}/screenshots/${path.basename(modalShotPath)}`).catch(() => void 0);
2161
+ await AppBrain.saveScreenshot(opts.tenantId, opts.projectId, modalScreen.id, modalShotPath, modalScreen.version, "DESKTOP", modalCloudUrl).catch(() => {
2073
2162
  });
2163
+ const modalDom = await capturePageHtml(page);
2164
+ if (modalDom) {
2165
+ const modalDomPath = modalShotPath.replace(/\.(png|jpg|jpeg)$/i, ".dom.html");
2166
+ await fs.writeFile(modalDomPath, modalDom, "utf8");
2167
+ const modalDomKey = `${opts.tenantId}/${opts.projectId}/dom-snapshots/${path.basename(modalDomPath)}`;
2168
+ const modalDomCloud = await uploadToCloud(modalDomPath, modalDomKey).catch(() => void 0);
2169
+ AppBrain.updateScreenDomSnapshot(opts.tenantId, modalScreen.id, modalDomPath, modalDomCloud).catch(() => {
2170
+ });
2171
+ }
2074
2172
  const modalElements = await domFallbackElements(page, rawPage ?? void 0).catch(() => []);
2075
2173
  for (const el of modalElements) {
2076
2174
  AppBrain.saveElement(opts.tenantId, opts.projectId, modalScreen.id, el.meaning, el.role, el.expectedData, el.notes, void 0, true, el.boundingRect, el.ariaState, el.parentLandmark).catch(() => {
@@ -2115,6 +2213,11 @@ async function crawlProject(opts, onProgress) {
2115
2213
  }
2116
2214
  }).catch(() => {
2117
2215
  });
2216
+ await opts.onCheckpoint?.(
2217
+ Array.from(visited.entries()).filter(([_k, v]) => v !== "_pending_").map(([k]) => k),
2218
+ queue.filter((u) => !visited.has(canonicalize(u)))
2219
+ ).catch(() => {
2220
+ });
2118
2221
  throw new Error(`[AUTH_FAILURE] Login wall detected: ${loginWallRedirectCount} URLs redirected to the login page. Add credentials in project settings and retry the crawl.`);
2119
2222
  }
2120
2223
  }
@@ -2150,6 +2253,11 @@ async function crawlProject(opts, onProgress) {
2150
2253
  payload: { url, landed: landedUrl, reason: "Re-authentication failed. Session is unrecoverable. Re-capture the session in project settings and retry the crawl." }
2151
2254
  }).catch(() => {
2152
2255
  });
2256
+ await opts.onCheckpoint?.(
2257
+ Array.from(visited.entries()).filter(([_k, v]) => v !== "_pending_").map(([k]) => k),
2258
+ queue.filter((u) => !visited.has(canonicalize(u)))
2259
+ ).catch(() => {
2260
+ });
2153
2261
  throw new Error("[AUTH_FAILURE] Re-authentication failed. Session is unrecoverable. Re-capture the session in project settings and retry the crawl.");
2154
2262
  }
2155
2263
  } else {
@@ -2164,6 +2272,11 @@ async function crawlProject(opts, onProgress) {
2164
2272
  payload: { url, landed: landedUrl, reason: "Session expired and no credentials are configured. Add credentials in project settings and re-capture the session." }
2165
2273
  }).catch(() => {
2166
2274
  });
2275
+ await opts.onCheckpoint?.(
2276
+ Array.from(visited.entries()).filter(([_k, v]) => v !== "_pending_").map(([k]) => k),
2277
+ queue.filter((u) => !visited.has(canonicalize(u)))
2278
+ ).catch(() => {
2279
+ });
2167
2280
  throw new Error("[AUTH_FAILURE] Session expired and no credentials are configured. Add credentials in project settings and re-capture the session.");
2168
2281
  }
2169
2282
  }
@@ -2762,120 +2875,139 @@ async function crawlProject(opts, onProgress) {
2762
2875
  })();
2763
2876
  }
2764
2877
  }
2765
- try {
2766
- const domContent = await capturePageHtml(page);
2767
- if (!domContent) {
2768
- logger.warn({ url, screenId: screen.id }, "[crawler] markdown extraction skipped: DOM snapshot unavailable");
2769
- } else {
2770
- const domPath = shotPath.replace(/\.(png|jpg|jpeg)$/i, ".dom.html");
2771
- await fs.writeFile(domPath, domContent, "utf8");
2772
- AppBrain.updateScreenDomSnapshot(opts.tenantId, screen.id, domPath).catch(() => {
2773
- });
2774
- const [iframeContent, shadowContent] = await Promise.all([
2775
- extractIframeContent(page).catch(() => []),
2776
- pierceShadowDom(page).catch(() => "")
2777
- ]);
2778
- const supplemental = [
2779
- ...iframeContent.map((f) => f.markdown).filter(Boolean),
2780
- shadowContent
2781
- ].join("\n\n").trim();
2782
- const extracted = extractMarkdown(
2783
- supplemental ? domContent + "\n<!-- iframe/shadow -->\n" + supplemental.slice(0, 3e3) : domContent,
2784
- url
2785
- );
2786
- let fitMarkdown;
2787
- if (extracted) {
2788
- const mdPath = shotPath.replace(/\.(png|jpg|jpeg)$/i, ".md");
2789
- fitMarkdown = buildFitMarkdown(extracted.markdown, [
2790
- ...domainProfile.importantScreenPatterns,
2791
- ...domainProfile.workflowGoals,
2792
- ...opts.bestFirstKeywords ?? []
2878
+ 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;
2879
+ const pageBodyText = await page.evaluate(() => document.body?.innerText ?? "").catch(() => "");
2880
+ if (INLINE_AUTH_WALL_RE.test(pageBodyText)) {
2881
+ logger.warn({ url, screenId: screen.id }, "[crawler] Inline session-expired content detected \u2014 skipping DOM snapshot, marking requiresAuth");
2882
+ AppBrain.updateScreenRequiresAuth(opts.tenantId, screen.id, true).catch(() => {
2883
+ });
2884
+ AppBrain.saveCrawlEvidence(opts.tenantId, opts.projectId, {
2885
+ screenId: screen.id,
2886
+ jobId: opts.jobId,
2887
+ type: "AUTH_FAILURE",
2888
+ payload: { url, reason: "Inline session-expired page rendered without URL redirect. Re-authenticate and re-crawl." }
2889
+ }).catch(() => {
2890
+ });
2891
+ } else {
2892
+ try {
2893
+ const domContent = await capturePageHtml(page);
2894
+ if (!domContent) {
2895
+ logger.warn({ url, screenId: screen.id }, "[crawler] markdown extraction skipped: DOM snapshot unavailable");
2896
+ } else {
2897
+ const domPath = shotPath.replace(/\.(png|jpg|jpeg)$/i, ".dom.html");
2898
+ await fs.writeFile(domPath, domContent, "utf8");
2899
+ (async () => {
2900
+ const domKey = `${opts.tenantId}/${opts.projectId}/dom-snapshots/${path.basename(domPath)}`;
2901
+ const domCloudUrl = await uploadToCloud(domPath, domKey).catch(() => void 0);
2902
+ AppBrain.updateScreenDomSnapshot(opts.tenantId, screen.id, domPath, domCloudUrl).catch(() => {
2903
+ });
2904
+ })();
2905
+ const [iframeContent, shadowContent] = await Promise.all([
2906
+ extractIframeContent(page).catch(() => []),
2907
+ pierceShadowDom(page).catch(() => "")
2793
2908
  ]);
2794
- const { text: scrubbed, scrubbed: hadPii } = scrubPii(extracted.markdown);
2795
- if (hadPii) logger.info({ url }, "[crawler] PII detected and redacted from markdown");
2796
- const dedupResult = deduplicator.check(url, scrubbed);
2797
- if (dedupResult.isDuplicate) {
2798
- logger.info({ url, similarUrl: dedupResult.similarUrl, score: dedupResult.similarityScore }, "[crawler] near-duplicate page skipped");
2799
- }
2800
- await fs.writeFile(mdPath, scrubbed, "utf8");
2801
- const summaryPromise = (async () => {
2802
- try {
2803
- const llmCfg = await getCachedCrawlLLM(opts.tenantId);
2804
- const snippet = extracted.markdown.slice(0, 1200);
2805
- if (snippet.trim().length < 80) return void 0;
2806
- const summarySeed = deterministicSeed(`${opts.tenantId}:${opts.projectId ?? ""}:page-summary:${url}`);
2807
- const { text, usage } = await askTracked(
2808
- "You are a QA analyst. Output ONLY a single complete sentence (no preamble, no quotes) describing what this screen does or shows. Be specific. Stop after the first sentence.",
2809
- `Screen: "${name}"
2909
+ const supplemental = [
2910
+ ...iframeContent.map((f) => f.markdown).filter(Boolean),
2911
+ shadowContent
2912
+ ].join("\n\n").trim();
2913
+ const extracted = extractMarkdown(
2914
+ supplemental ? domContent + "\n<!-- iframe/shadow -->\n" + supplemental.slice(0, 3e3) : domContent,
2915
+ url
2916
+ );
2917
+ let fitMarkdown;
2918
+ if (extracted) {
2919
+ const mdPath = shotPath.replace(/\.(png|jpg|jpeg)$/i, ".md");
2920
+ fitMarkdown = buildFitMarkdown(extracted.markdown, [
2921
+ ...domainProfile.importantScreenPatterns,
2922
+ ...domainProfile.workflowGoals,
2923
+ ...opts.bestFirstKeywords ?? []
2924
+ ]);
2925
+ const { text: scrubbed, scrubbed: hadPii } = scrubPii(extracted.markdown);
2926
+ if (hadPii) logger.info({ url }, "[crawler] PII detected and redacted from markdown");
2927
+ const dedupResult = deduplicator.check(url, scrubbed);
2928
+ if (dedupResult.isDuplicate) {
2929
+ logger.info({ url, similarUrl: dedupResult.similarUrl, score: dedupResult.similarityScore }, "[crawler] near-duplicate page skipped");
2930
+ }
2931
+ await fs.writeFile(mdPath, scrubbed, "utf8");
2932
+ const summaryPromise = (async () => {
2933
+ try {
2934
+ const llmCfg = await getCachedCrawlLLM(opts.tenantId);
2935
+ const snippet = extracted.markdown.slice(0, 1200);
2936
+ if (snippet.trim().length < 80) return void 0;
2937
+ const summarySeed = deterministicSeed(`${opts.tenantId}:${opts.projectId ?? ""}:page-summary:${url}`);
2938
+ const { text, usage } = await askTracked(
2939
+ "You are a QA analyst. Output ONLY a single complete sentence (no preamble, no quotes) describing what this screen does or shows. Be specific. Stop after the first sentence.",
2940
+ `Screen: "${name}"
2810
2941
  URL: ${url}
2811
2942
 
2812
2943
  Content:
2813
2944
  ${snippet}`,
2814
- llmCfg,
2815
- 200,
2816
- void 0,
2817
- { temperature: 0, seed: summarySeed }
2818
- );
2819
- if (usage) {
2820
- recordLLMCall({
2821
- tenantId: opts.tenantId,
2822
- projectId: opts.projectId,
2823
- jobId: opts.jobId,
2824
- agentType: "crawl_summary",
2825
- label: `page_summary:${name}`,
2826
- usage
2827
- }).catch(() => {
2828
- });
2945
+ llmCfg,
2946
+ 200,
2947
+ void 0,
2948
+ { temperature: 0, seed: summarySeed }
2949
+ );
2950
+ if (usage) {
2951
+ recordLLMCall({
2952
+ tenantId: opts.tenantId,
2953
+ projectId: opts.projectId,
2954
+ jobId: opts.jobId,
2955
+ agentType: "crawl_summary",
2956
+ label: `page_summary:${name}`,
2957
+ usage
2958
+ }).catch(() => {
2959
+ });
2960
+ }
2961
+ const clean = text.trim().replace(/^["']|["']$/g, "");
2962
+ const firstSentence = clean.match(/^[^.!?]+[.!?]/)?.[0] ?? clean;
2963
+ return firstSentence.trim();
2964
+ } catch {
2965
+ return void 0;
2829
2966
  }
2830
- const clean = text.trim().replace(/^["']|["']$/g, "");
2831
- const firstSentence = clean.match(/^[^.!?]+[.!?]/)?.[0] ?? clean;
2832
- return firstSentence.trim();
2833
- } catch {
2834
- return void 0;
2835
- }
2836
- })();
2837
- AppBrain.updateScreenMarkdown(opts.tenantId, opts.projectId, screen.id, {
2838
- markdownPath: mdPath,
2839
- markdownMethod: extracted.method,
2840
- markdownWordCount: extracted.wordCount,
2841
- markdown: fitMarkdown ?? extracted.markdown,
2842
- heading: extracted.title || name
2843
- }).catch((err) => console.warn("[crawler] markdown extraction save failed:", err));
2844
- summaryPromise.then((aiSummary) => {
2845
- if (aiSummary) AppBrain.updateScreenAiSummary(opts.tenantId, screen.id, aiSummary).catch(() => {
2967
+ })();
2968
+ AppBrain.updateScreenMarkdown(opts.tenantId, opts.projectId, screen.id, {
2969
+ markdownPath: mdPath,
2970
+ markdownMethod: extracted.method,
2971
+ markdownWordCount: extracted.wordCount,
2972
+ markdown: fitMarkdown ?? extracted.markdown,
2973
+ heading: extracted.title || name
2974
+ }).catch((err) => console.warn("[crawler] markdown extraction save failed:", err));
2975
+ summaryPromise.then((aiSummary) => {
2976
+ if (aiSummary) AppBrain.updateScreenAiSummary(opts.tenantId, screen.id, aiSummary).catch(() => {
2977
+ });
2846
2978
  });
2979
+ }
2980
+ const structuredJson = summarizeStructuredElements(atElements);
2981
+ const artifact = buildCrawlArtifactEnvelope({
2982
+ tenantId: opts.tenantId,
2983
+ projectId: opts.projectId,
2984
+ jobId: opts.jobId,
2985
+ url,
2986
+ canonicalUrl: canonicalize(url),
2987
+ screenId: screen.id,
2988
+ screenshotPath: shotPath,
2989
+ domPath,
2990
+ domContent,
2991
+ markdown: extracted?.markdown,
2992
+ fitMarkdown,
2993
+ accessibilityElements: atElements,
2994
+ structuredJson,
2995
+ evidence: [
2996
+ { type: "domain_profile", payload: { industry: domainProfile.industry, confidence: domainProfile.confidence } },
2997
+ { type: "engine_route", payload: routeDecision },
2998
+ { type: "screen_fingerprints", payload: { urlCanonicalHash: sha256(canonicalize(url)) } }
2999
+ ]
2847
3000
  });
3001
+ AppBrain.saveCrawlEvidence(opts.tenantId, opts.projectId, {
3002
+ screenId: screen.id,
3003
+ jobId: opts.jobId,
3004
+ type: "SCREEN_ARTIFACT_ENVELOPE",
3005
+ payload: artifact
3006
+ }).catch((err) => console.warn("[crawler] artifact evidence save failed:", err?.message ?? err));
2848
3007
  }
2849
- const structuredJson = summarizeStructuredElements(atElements);
2850
- const artifact = buildCrawlArtifactEnvelope({
2851
- tenantId: opts.tenantId,
2852
- projectId: opts.projectId,
2853
- jobId: opts.jobId,
2854
- url,
2855
- canonicalUrl: canonicalize(url),
2856
- screenId: screen.id,
2857
- screenshotPath: shotPath,
2858
- domPath,
2859
- domContent,
2860
- markdown: extracted?.markdown,
2861
- fitMarkdown,
2862
- accessibilityElements: atElements,
2863
- structuredJson,
2864
- evidence: [
2865
- { type: "domain_profile", payload: { industry: domainProfile.industry, confidence: domainProfile.confidence } },
2866
- { type: "engine_route", payload: routeDecision },
2867
- { type: "screen_fingerprints", payload: { urlCanonicalHash: sha256(canonicalize(url)) } }
2868
- ]
2869
- });
2870
- AppBrain.saveCrawlEvidence(opts.tenantId, opts.projectId, {
2871
- screenId: screen.id,
2872
- jobId: opts.jobId,
2873
- type: "SCREEN_ARTIFACT_ENVELOPE",
2874
- payload: artifact
2875
- }).catch((err) => console.warn("[crawler] artifact evidence save failed:", err?.message ?? err));
3008
+ } catch (err) {
3009
+ logger.warn({ url, screenId: screen.id, err: String(err?.message ?? err) }, "[crawler] markdown extraction failed");
2876
3010
  }
2877
- } catch (err) {
2878
- logger.warn({ url, screenId: screen.id, err: String(err?.message ?? err) }, "[crawler] markdown extraction failed");
2879
3011
  }
2880
3012
  if (a11yScore !== null) {
2881
3013
  AppBrain.updateScreenA11y(opts.tenantId, screen.id, { a11yScore }).catch((err) => console.warn("[crawler] a11y score update failed:", err));
@@ -3046,6 +3178,8 @@ ${snippet}`,
3046
3178
  const driftedToAuthWall = !weWantedThisPage && (BFS_LOGIN_PATH.test(postExtractParsed.pathname) || BFS_ERROR_PATH.test(postExtractParsed.pathname));
3047
3179
  if (driftedToAuthWall) {
3048
3180
  logger.warn({ url, postExtractUrl }, "[crawler] Page drifted to auth wall during element extraction \u2014 skipping element save to prevent login-page element contamination");
3181
+ AppBrain.updateScreenRequiresAuth(opts.tenantId, screen.id, true).catch(() => {
3182
+ });
3049
3183
  AppBrain.saveCrawlEvidence(opts.tenantId, opts.projectId, {
3050
3184
  screenId: screen.id,
3051
3185
  jobId: opts.jobId,
@@ -3063,8 +3197,8 @@ ${snippet}`,
3063
3197
  } else {
3064
3198
  await AppBrain.clearScreenElements(opts.tenantId, opts.projectId, screen.id).catch(() => {
3065
3199
  });
3066
- for (let _ei = 0; _ei < elements.length; _ei += 4) {
3067
- const _batch = elements.slice(_ei, _ei + 4);
3200
+ for (let _ei = 0; _ei < elements.length; _ei += 2) {
3201
+ const _batch = elements.slice(_ei, _ei + 2);
3068
3202
  await Promise.all(_batch.map(async (el) => {
3069
3203
  const saved = await AppBrain.saveElement(opts.tenantId, opts.projectId, screen.id, el.meaning, el.role, el.expectedData, el.notes, void 0, true, el.boundingRect, el.ariaState, el.parentLandmark);
3070
3204
  const label = el.meaning;
@@ -3075,7 +3209,7 @@ ${snippet}`,
3075
3209
  { type: "aria-label", value: label, confidence: 0.85 },
3076
3210
  { type: "tag-hint", value: selectorHint, confidence: 0.6 }
3077
3211
  ];
3078
- AppBrain.saveElementFingerprint(opts.tenantId, opts.projectId, saved.id, selectors).catch(console.warn);
3212
+ await AppBrain.saveElementFingerprint(opts.tenantId, opts.projectId, saved.id, selectors).catch(console.warn);
3079
3213
  }));
3080
3214
  }
3081
3215
  }
@@ -3104,7 +3238,7 @@ ${snippet}`,
3104
3238
  try {
3105
3239
  const targetPath = new URL(lm.href).pathname;
3106
3240
  const canonLmHref = canonicalize(lm.href);
3107
- if (!SKIP_PATH.test(targetPath) && isUsefulNavigationUrl(lm.href, origin) && !queued.has(canonLmHref) && !visited.has(canonLmHref) && queued.size < maxScreens * 2) {
3241
+ if (!SKIP_PATH.test(targetPath) && isUsefulNavigationUrl(lm.href, origin) && !queued.has(canonLmHref) && !visited.has(canonLmHref) && queued.size < maxScreens * 3) {
3108
3242
  if (robotsCache && !await robotsCache.isAllowed(lm.href)) continue;
3109
3243
  queued.add(canonLmHref);
3110
3244
  queue.push(lm.href, { parentElements: elements.length });
@@ -3125,7 +3259,7 @@ ${snippet}`,
3125
3259
  } catch {
3126
3260
  }
3127
3261
  const canonHref = canonicalize(href);
3128
- if (!queued.has(canonHref) && !visited.has(canonHref) && queued.size < maxScreens * 2) {
3262
+ if (!queued.has(canonHref) && !visited.has(canonHref) && queued.size < maxScreens * 3) {
3129
3263
  try {
3130
3264
  const hrefPath = new URL(href).pathname;
3131
3265
  if (isGeneratedDynamicPath(hrefPath)) continue;
@@ -3178,7 +3312,7 @@ ${snippet}`,
3178
3312
  const jsDetectedRoutes = await detectJsRoutes(page, origin);
3179
3313
  for (const route of jsDetectedRoutes) {
3180
3314
  const canonRoute = canonicalize(route);
3181
- if (isUsefulNavigationUrl(route, origin) && !queued.has(canonRoute) && !visited.has(canonRoute) && queued.size < maxScreens * 2) {
3315
+ if (isUsefulNavigationUrl(route, origin) && !queued.has(canonRoute) && !visited.has(canonRoute) && queued.size < maxScreens * 3) {
3182
3316
  queued.add(canonRoute);
3183
3317
  queue.push(route, { parentElements: elements.length });
3184
3318
  logger.info({ route }, "[P2] JS router: discovered route added to queue");
@@ -3192,7 +3326,7 @@ ${snippet}`,
3192
3326
  try {
3193
3327
  const abs = `${origin}${relPath}`;
3194
3328
  const canon = canonicalize(abs);
3195
- if (isUsefulNavigationUrl(abs, origin) && !queued.has(canon) && !visited.has(canon) && queued.size < maxScreens * 2) {
3329
+ if (isUsefulNavigationUrl(abs, origin) && !queued.has(canon) && !visited.has(canon) && queued.size < maxScreens * 3) {
3196
3330
  queued.add(canon);
3197
3331
  queue.push(abs);
3198
3332
  logger.info({ url: abs }, "[P4] XHR: page URL from JSON response added to queue");
@@ -3200,7 +3334,7 @@ ${snippet}`,
3200
3334
  } catch {
3201
3335
  }
3202
3336
  }
3203
- if (nextLinks.length === 0 && elements.length < (parseInt(process.env.CRAWL_SKYVERN_PAGE_MIN_ELEMENTS ?? "") || 5) && opts.skyvernConfig) {
3337
+ if (!selective && nextLinks.length === 0 && elements.length < (parseInt(process.env.CRAWL_SKYVERN_PAGE_MIN_ELEMENTS ?? "") || 5) && opts.skyvernConfig) {
3204
3338
  try {
3205
3339
  logger.info({ url }, "Gap 5: thin page detected \u2014 asking Skyvern to discover navigation URLs");
3206
3340
  const skyvernNav = await skyvernNavigate(
@@ -3210,7 +3344,7 @@ ${snippet}`,
3210
3344
  );
3211
3345
  for (const skyvernUrl of skyvernNav.visitedUrls) {
3212
3346
  const canonSkyvernUrl = canonicalize(skyvernUrl);
3213
- if (!queued.has(canonSkyvernUrl) && !visited.has(canonSkyvernUrl) && queued.size < maxScreens * 2) {
3347
+ if (!queued.has(canonSkyvernUrl) && !visited.has(canonSkyvernUrl) && queued.size < maxScreens * 3) {
3214
3348
  logger.info({ url: skyvernUrl }, "Gap 5: Skyvern-discovered URL added to ZeTa crawl queue");
3215
3349
  queued.add(canonSkyvernUrl);
3216
3350
  queue.push(skyvernUrl);
@@ -3265,9 +3399,9 @@ ${snippet}`,
3265
3399
  lastEmittedPct = clampedPct;
3266
3400
  await onProgress?.(clampedPct, `Crawling screens (${visited.size} mapped, ${queue.length} pending)`);
3267
3401
  logger.info({ name, elements: elements.length }, "Screen crawled");
3268
- if (++checkpointSuccessCount % 10 === 0) {
3402
+ if (++checkpointSuccessCount % 5 === 0) {
3269
3403
  await opts.onCheckpoint?.(
3270
- Array.from(visited.keys()),
3404
+ Array.from(visited.entries()).filter(([_k, v]) => v !== "_pending_").map(([k]) => k),
3271
3405
  queue.filter((u) => !visited.has(canonicalize(u)))
3272
3406
  );
3273
3407
  }
@@ -3295,7 +3429,7 @@ ${snippet}`,
3295
3429
  }
3296
3430
  const successfulScreenCount = discovered.filter((d) => d.screenId).length;
3297
3431
  const skyvernMinScreens = parseInt(process.env.CRAWL_SKYVERN_MIN_SCREENS ?? "") || 10;
3298
- if (successfulScreenCount < skyvernMinScreens && opts.skyvernConfig) {
3432
+ if (!selective && successfulScreenCount < skyvernMinScreens && opts.skyvernConfig) {
3299
3433
  await onProgress?.(78, "Few screens found \u2014 using Skyvern to discover additional navigation URLs");
3300
3434
  const currentUrl = page.url();
3301
3435
  const skyvernNav = await skyvernNavigate(
@@ -3310,7 +3444,7 @@ ${snippet}`,
3310
3444
  if (skyvernUrls.length > 0) {
3311
3445
  logger.info({ count: skyvernUrls.length }, "Gap 5: Skyvern found new URLs \u2014 processing with ZeTa native crawler");
3312
3446
  for (const skyvernUrl of skyvernUrls) {
3313
- if (visited.size >= maxScreens) break;
3447
+ if (isCrawlTimedOut() || visited.size >= maxScreens) break;
3314
3448
  try {
3315
3449
  await page.goto(skyvernUrl, { waitUntil: "domcontentloaded", timeout: 2e4 });
3316
3450
  await new Promise((r) => setTimeout(r, 1500));
@@ -3319,7 +3453,17 @@ ${snippet}`,
3319
3453
  const shotPath = `${opts.screenshotDir ?? "/tmp"}/${screen.id}-v${screen.version}.png`;
3320
3454
  await waitForScreenshotReady(page, rawPage ?? void 0);
3321
3455
  await page.screenshot({ path: shotPath, fullPage: true });
3322
- await AppBrain.saveScreenshot(opts.tenantId, opts.projectId, screen.id, shotPath, screen.version, "DESKTOP");
3456
+ const skyvernCloudUrl = await uploadToCloud(shotPath, `${opts.tenantId}/${opts.projectId}/screenshots/${path.basename(shotPath)}`).catch(() => void 0);
3457
+ await AppBrain.saveScreenshot(opts.tenantId, opts.projectId, screen.id, shotPath, screen.version, "DESKTOP", skyvernCloudUrl);
3458
+ const skyvernDom = await capturePageHtml(page);
3459
+ if (skyvernDom) {
3460
+ const skyvernDomPath = shotPath.replace(/\.(png|jpg|jpeg)$/i, ".dom.html");
3461
+ await fs.writeFile(skyvernDomPath, skyvernDom, "utf8");
3462
+ const skyvernDomKey = `${opts.tenantId}/${opts.projectId}/dom-snapshots/${path.basename(skyvernDomPath)}`;
3463
+ const skyvernDomCloud = await uploadToCloud(skyvernDomPath, skyvernDomKey).catch(() => void 0);
3464
+ AppBrain.updateScreenDomSnapshot(opts.tenantId, screen.id, skyvernDomPath, skyvernDomCloud).catch(() => {
3465
+ });
3466
+ }
3323
3467
  const [elems, links, linkMappings] = await Promise.all([
3324
3468
  extractElements(stagehand, page, elementLLM ?? void 0, rawPage ?? void 0),
3325
3469
  discoverLinks(stagehand, origin, page, linkLLM ?? void 0, aiSkipCount > 0, rawPage ?? void 0),
@@ -3345,7 +3489,7 @@ ${snippet}`,
3345
3489
  logger.warn({ url: skyvernUrl, err: String(err) }, "Gap 5: ZeTa crawl of Skyvern URL failed");
3346
3490
  }
3347
3491
  }
3348
- while (queue.length > 0 && visited.size < maxScreens) {
3492
+ while (queue.length > 0 && visited.size < maxScreens && !isCrawlTimedOut()) {
3349
3493
  const href = queue.shift();
3350
3494
  const canonHref = canonicalize(href);
3351
3495
  if (visited.has(canonHref)) continue;
@@ -3358,7 +3502,17 @@ ${snippet}`,
3358
3502
  const sp = `${opts.screenshotDir ?? "/tmp"}/${sc.id}-v${sc.version}.png`;
3359
3503
  await waitForScreenshotReady(page, rawPage ?? void 0);
3360
3504
  await page.screenshot({ path: sp, fullPage: true });
3361
- await AppBrain.saveScreenshot(opts.tenantId, opts.projectId, sc.id, sp, sc.version, "DESKTOP");
3505
+ const skyvernDrainCloudUrl = await uploadToCloud(sp, `${opts.tenantId}/${opts.projectId}/screenshots/${path.basename(sp)}`).catch(() => void 0);
3506
+ await AppBrain.saveScreenshot(opts.tenantId, opts.projectId, sc.id, sp, sc.version, "DESKTOP", skyvernDrainCloudUrl);
3507
+ const skyvernDrainDom = await capturePageHtml(page);
3508
+ if (skyvernDrainDom) {
3509
+ const skyvernDrainDomPath = sp.replace(/\.(png|jpg|jpeg)$/i, ".dom.html");
3510
+ await fs.writeFile(skyvernDrainDomPath, skyvernDrainDom, "utf8");
3511
+ const skyvernDrainDomKey = `${opts.tenantId}/${opts.projectId}/dom-snapshots/${path.basename(skyvernDrainDomPath)}`;
3512
+ const skyvernDrainDomCloud = await uploadToCloud(skyvernDrainDomPath, skyvernDrainDomKey).catch(() => void 0);
3513
+ AppBrain.updateScreenDomSnapshot(opts.tenantId, sc.id, skyvernDrainDomPath, skyvernDrainDomCloud).catch(() => {
3514
+ });
3515
+ }
3362
3516
  const [elems2, linkMappings2] = await Promise.all([
3363
3517
  extractElements(stagehand, page, elementLLM ?? void 0, rawPage ?? void 0),
3364
3518
  extractLinkMappings(page, origin)
@@ -3413,7 +3567,8 @@ ${snippet}`,
3413
3567
  if (revisitQueue.length > 0) {
3414
3568
  await onProgress?.(79, `Re-visiting ${revisitQueue.length} page(s) with async content\u2026`);
3415
3569
  logger.info({ count: revisitQueue.length }, "[P3a] Starting async content re-visit pass");
3416
- for (const revisitUrl of revisitQueue.slice(0, 10)) {
3570
+ for (const revisitUrl of revisitQueue.slice(0, 20)) {
3571
+ if (isCrawlTimedOut()) break;
3417
3572
  const rawScreenId = visited.get(canonicalize(revisitUrl));
3418
3573
  const isAuthWallRevisit = rawScreenId === "_auth_wall_";
3419
3574
  if (!rawScreenId) continue;
@@ -3439,8 +3594,18 @@ ${snippet}`,
3439
3594
  }
3440
3595
  const shotPath2 = `${opts.screenshotDir ?? "/tmp"}/${existingScreenId}-revisit.png`;
3441
3596
  await page.screenshot({ path: shotPath2, fullPage: true });
3442
- await AppBrain.saveScreenshot(opts.tenantId, opts.projectId, existingScreenId, shotPath2, 1, "DESKTOP", void 0).catch(() => {
3597
+ const p3aCloudUrl = await uploadToCloud(shotPath2, `${opts.tenantId}/${opts.projectId}/screenshots/${path.basename(shotPath2)}`).catch(() => void 0);
3598
+ await AppBrain.saveScreenshot(opts.tenantId, opts.projectId, existingScreenId, shotPath2, 1, "DESKTOP", p3aCloudUrl).catch(() => {
3443
3599
  });
3600
+ const p3aDom = await capturePageHtml(page);
3601
+ if (p3aDom) {
3602
+ const p3aDomPath = shotPath2.replace(/\.(png|jpg|jpeg)$/i, ".dom.html");
3603
+ await fs.writeFile(p3aDomPath, p3aDom, "utf8");
3604
+ const p3aDomKey = `${opts.tenantId}/${opts.projectId}/dom-snapshots/${path.basename(p3aDomPath)}`;
3605
+ const p3aDomCloud = await uploadToCloud(p3aDomPath, p3aDomKey).catch(() => void 0);
3606
+ AppBrain.updateScreenDomSnapshot(opts.tenantId, existingScreenId, p3aDomPath, p3aDomCloud).catch(() => {
3607
+ });
3608
+ }
3444
3609
  const revisitElements = await extractElements(stagehand, page, elementLLM ?? void 0, rawPage ?? void 0).catch(() => []);
3445
3610
  if (revisitElements.length > 0) {
3446
3611
  await AppBrain.clearScreenElements(opts.tenantId, opts.projectId, existingScreenId).catch(() => {
@@ -3453,7 +3618,7 @@ ${snippet}`,
3453
3618
  const revisitLinks = await discoverLinks(stagehand, origin, page, linkLLM ?? void 0, false, rawPage ?? void 0).catch(() => []);
3454
3619
  for (const href of revisitLinks) {
3455
3620
  const canon = canonicalize(href);
3456
- if (isUsefulNavigationUrl(href, origin) && !queued.has(canon) && !visited.has(canon) && queued.size < maxScreens * 2) {
3621
+ if (isUsefulNavigationUrl(href, origin) && !queued.has(canon) && !visited.has(canon) && queued.size < maxScreens * 3) {
3457
3622
  queued.add(canon);
3458
3623
  queue.push(href);
3459
3624
  }
@@ -3469,6 +3634,7 @@ ${snippet}`,
3469
3634
  logger.info({ newLinks: p3bCount }, "[P3b] Extended BFS \u2014 processing links discovered during revisit pass");
3470
3635
  await onProgress?.(82, `Extended crawl: ${p3bCount} newly discovered page(s)\u2026`);
3471
3636
  while (queue.length > 0 && visited.size < maxScreens && visited.size < segmentSize) {
3637
+ if (isCrawlTimedOut()) break;
3472
3638
  const href = queue.shift();
3473
3639
  if (!href) break;
3474
3640
  const canonHref = canonicalize(href);
@@ -3489,6 +3655,15 @@ ${snippet}`,
3489
3655
  const cloudUrl3 = await uploadToCloud(sp, `${opts.tenantId}/${opts.projectId}/screenshots/${path.basename(sp)}`).catch(() => "");
3490
3656
  await AppBrain.saveScreenshot(opts.tenantId, opts.projectId, screen.id, sp, screen.version, "DESKTOP", cloudUrl3).catch(() => {
3491
3657
  });
3658
+ const p3bDom = await capturePageHtml(page);
3659
+ if (p3bDom) {
3660
+ const p3bDomPath = sp.replace(/\.(png|jpg|jpeg)$/i, ".dom.html");
3661
+ await fs.writeFile(p3bDomPath, p3bDom, "utf8");
3662
+ const p3bDomKey = `${opts.tenantId}/${opts.projectId}/dom-snapshots/${path.basename(p3bDomPath)}`;
3663
+ const p3bDomCloud = await uploadToCloud(p3bDomPath, p3bDomKey).catch(() => void 0);
3664
+ AppBrain.updateScreenDomSnapshot(opts.tenantId, screen.id, p3bDomPath, p3bDomCloud).catch(() => {
3665
+ });
3666
+ }
3492
3667
  const [p3bElems, p3bLinks] = await Promise.all([
3493
3668
  extractElements(stagehand, page, elementLLM ?? void 0, rawPage ?? void 0).catch(() => []),
3494
3669
  discoverLinks(stagehand, origin, page, linkLLM ?? void 0, aiSkipCount > 0, rawPage ?? void 0).catch(() => [])
@@ -3496,8 +3671,8 @@ ${snippet}`,
3496
3671
  if (p3bElems.length > 0) {
3497
3672
  await AppBrain.clearScreenElements(opts.tenantId, opts.projectId, screen.id).catch(() => {
3498
3673
  });
3499
- for (let _ei3 = 0; _ei3 < p3bElems.length; _ei3 += 4) {
3500
- const _b3 = p3bElems.slice(_ei3, _ei3 + 4);
3674
+ for (let _ei3 = 0; _ei3 < p3bElems.length; _ei3 += 2) {
3675
+ const _b3 = p3bElems.slice(_ei3, _ei3 + 2);
3501
3676
  await Promise.all(_b3.map(
3502
3677
  (el) => AppBrain.saveElement(opts.tenantId, opts.projectId, screen.id, el.meaning, el.role, el.expectedData, el.notes, void 0, true, el.boundingRect, el.ariaState, el.parentLandmark).catch(() => {
3503
3678
  })
@@ -3506,7 +3681,7 @@ ${snippet}`,
3506
3681
  }
3507
3682
  for (const link of p3bLinks) {
3508
3683
  const c = canonicalize(link);
3509
- if (isUsefulNavigationUrl(link, origin) && !queued.has(c) && !visited.has(c) && queued.size < maxScreens * 2) {
3684
+ if (isUsefulNavigationUrl(link, origin) && !queued.has(c) && !visited.has(c) && queued.size < maxScreens * 3) {
3510
3685
  queued.add(c);
3511
3686
  queue.push(link);
3512
3687
  }
@@ -3543,9 +3718,9 @@ ${snippet}`,
3543
3718
  });
3544
3719
  logger.info({ confirmed: confirmedGaps.length, total: gapCandidates.length }, "[P6] Gap pass: visiting confirmed gap URLs");
3545
3720
  const gapLoginPattern = /\/(login|signin|sign-in|auth|log-in)(\/|$|\?)/i;
3546
- for (const gap of confirmedGaps.slice(0, parseInt(process.env.CRAWLER_GAP_PASS_MAX_VISITS ?? "") || 8)) {
3721
+ for (const gap of confirmedGaps.slice(0, parseInt(process.env.CRAWLER_GAP_PASS_MAX_VISITS ?? "") || 25)) {
3722
+ if (isCrawlTimedOut() || visited.size >= maxScreens) break;
3547
3723
  const gapUrl = gap.confirmedUrl;
3548
- if (visited.size >= maxScreens) break;
3549
3724
  try {
3550
3725
  await page.goto(gapUrl, { waitUntil: "domcontentloaded", timeout: 2e4 });
3551
3726
  if (gapLoginPattern.test(page.url())) {
@@ -3558,7 +3733,17 @@ ${snippet}`,
3558
3733
  const gapShot = `${opts.screenshotDir ?? "/tmp"}/${gapScreen.id}-v${gapScreen.version}.png`;
3559
3734
  await waitForScreenshotReady(page, rawPage ?? void 0);
3560
3735
  await page.screenshot({ path: gapShot, fullPage: true });
3561
- await AppBrain.saveScreenshot(opts.tenantId, opts.projectId, gapScreen.id, gapShot, gapScreen.version, "DESKTOP");
3736
+ const gapCloudUrl = await uploadToCloud(gapShot, `${opts.tenantId}/${opts.projectId}/screenshots/${path.basename(gapShot)}`).catch(() => void 0);
3737
+ await AppBrain.saveScreenshot(opts.tenantId, opts.projectId, gapScreen.id, gapShot, gapScreen.version, "DESKTOP", gapCloudUrl);
3738
+ const gapDom = await capturePageHtml(page);
3739
+ if (gapDom) {
3740
+ const gapDomPath = gapShot.replace(/\.(png|jpg|jpeg)$/i, ".dom.html");
3741
+ await fs.writeFile(gapDomPath, gapDom, "utf8");
3742
+ const gapDomKey = `${opts.tenantId}/${opts.projectId}/dom-snapshots/${path.basename(gapDomPath)}`;
3743
+ const gapDomCloud = await uploadToCloud(gapDomPath, gapDomKey).catch(() => void 0);
3744
+ AppBrain.updateScreenDomSnapshot(opts.tenantId, gapScreen.id, gapDomPath, gapDomCloud).catch(() => {
3745
+ });
3746
+ }
3562
3747
  const [gapElems, gapLinks, gapLinkMaps] = await Promise.all([
3563
3748
  extractElements(stagehand, page, elementLLM ?? void 0, rawPage ?? void 0).catch(() => []),
3564
3749
  discoverLinks(stagehand, origin, page, linkLLM ?? void 0, false, rawPage ?? void 0).catch(() => []),
@@ -3575,7 +3760,7 @@ ${snippet}`,
3575
3760
  }
3576
3761
  for (const href of gapLinks) {
3577
3762
  const canon = canonicalize(href);
3578
- if (isUsefulNavigationUrl(href, origin) && !queued.has(canon) && !visited.has(canon) && queued.size < maxScreens * 2) {
3763
+ if (isUsefulNavigationUrl(href, origin) && !queued.has(canon) && !visited.has(canon) && queued.size < maxScreens * 3) {
3579
3764
  queued.add(canon);
3580
3765
  queue.push(href);
3581
3766
  }
@@ -3777,6 +3962,7 @@ ${snippet}`,
3777
3962
  });
3778
3963
  const mobilePage = await mobileCtx.newPage();
3779
3964
  for (const disc of discovered) {
3965
+ if (isCrawlTimedOut()) break;
3780
3966
  if (!disc.screenId) continue;
3781
3967
  try {
3782
3968
  await mobilePage.goto(disc.url, { waitUntil: "domcontentloaded", timeout: 2e4 });
@@ -3938,8 +4124,14 @@ ${snippet}`,
3938
4124
  }
3939
4125
  })();
3940
4126
  await onProgress?.(98, "Finalizing");
4127
+ const wasTimeout = isCrawlTimedOut();
3941
4128
  const pendingUrls = queue.filter((u) => !visited.has(canonicalize(u)));
4129
+ const visitedUrls = Array.from(visited.entries()).filter(([_k, v]) => v !== "_pending_").map(([k]) => k);
3942
4130
  const hasMoreSegments = pendingUrls.length > 0;
4131
+ if (wasTimeout && pendingUrls.length > 0) {
4132
+ const elapsedMin = Math.round((Date.now() - crawlLoopStart) / 6e4);
4133
+ crawlWarnings.push(`global-timeout: crawl stopped after ${elapsedMin} minutes with ${pendingUrls.length} URLs remaining`);
4134
+ }
3943
4135
  const screensSucceeded = discovered.filter((d) => !!d.screenId).length;
3944
4136
  const screensErrored = discovered.filter((d) => !!d.error && !d.screenId).length;
3945
4137
  if (screensSucceeded >= 3) {
@@ -3963,6 +4155,10 @@ ${snippet}`,
3963
4155
  performanceMetrics,
3964
4156
  pendingUrls,
3965
4157
  // feed into next segment's resumeUrls
4158
+ visitedUrls,
4159
+ // all fully-processed URLs for skipUrls in continuation
4160
+ wasTimeout,
4161
+ // true when global timeout triggered the BFS break
3966
4162
  segmentIndex: segIdx,
3967
4163
  // which segment this was
3968
4164
  hasMoreSegments,
@@ -4016,7 +4212,7 @@ async function extractElements(stagehand, page, llmCfg, rawPage) {
4016
4212
  const iframeUrl = await frame.url().catch(() => "");
4017
4213
  if (!iframeUrl || iframeUrl === "about:blank" || iframeUrl.startsWith("javascript:")) continue;
4018
4214
  const frameEls = await domFallbackElements(frame).catch(() => []);
4019
- for (const el of frameEls.slice(0, 10)) {
4215
+ for (const el of frameEls.slice(0, 30)) {
4020
4216
  iframeElements.push({ ...el, notes: el.notes ? `${el.notes} [in iframe]` : "[in iframe]" });
4021
4217
  }
4022
4218
  }
@@ -4036,7 +4232,7 @@ async function extractElements(stagehand, page, llmCfg, rawPage) {
4036
4232
  seen.add(label);
4037
4233
  out.push({ meaning: label, role, notes: "[dynamic/revealed]" });
4038
4234
  });
4039
- return out.slice(0, 10);
4235
+ return out.slice(0, 30);
4040
4236
  }).catch(() => []);
4041
4237
  } catch {
4042
4238
  dynamicElements = [];
@@ -4081,7 +4277,7 @@ async function extractElements(stagehand, page, llmCfg, rawPage) {
4081
4277
  ]);
4082
4278
  if (!extracted) return domElements;
4083
4279
  const filtered = extracted.elements.filter((e) => !ELEMENT_NOISE.test(e.meaning ?? ""));
4084
- return filtered.slice(0, 20);
4280
+ return filtered.slice(0, 40);
4085
4281
  } catch {
4086
4282
  return domElements;
4087
4283
  }
@@ -4104,7 +4300,7 @@ async function discoverLinks(stagehand, origin, page, llmCfg, skipAi = false, ra
4104
4300
  });
4105
4301
  }
4106
4302
  const allDomLinks = Array.from(/* @__PURE__ */ new Set([...domLinks, ...interactiveLinks]));
4107
- if (allDomLinks.length >= 30 || skipAi) return allDomLinks;
4303
+ if (allDomLinks.length >= 50 || skipAi) return allDomLinks;
4108
4304
  try {
4109
4305
  const extracted = await Promise.race([
4110
4306
  stagehand.extract(
@@ -4426,7 +4622,7 @@ async function contentFallbackElements(page) {
4426
4622
  const mainContent = document.querySelector('main, [role="main"], article') ?? document.body;
4427
4623
  let listItemCount = 0;
4428
4624
  mainContent.querySelectorAll("li").forEach((li) => {
4429
- if (listItemCount >= 30) return;
4625
+ if (listItemCount >= 50) return;
4430
4626
  if (!isVisible(li)) return;
4431
4627
  const text = li.textContent?.replace(/\s+/g, " ").trim().slice(0, 150);
4432
4628
  if (text && text.length > 3 && text.length < 200) {
@@ -4641,7 +4837,7 @@ async function contentFallbackElements(page) {
4641
4837
  const navTags = /* @__PURE__ */ new Set(["nav", "header", "footer"]);
4642
4838
  let contentLinkCount = 0;
4643
4839
  document.querySelectorAll("a[href]").forEach((a) => {
4644
- if (contentLinkCount >= 20) return;
4840
+ if (contentLinkCount >= 40) return;
4645
4841
  if (!isVisible(a)) return;
4646
4842
  let p = a.parentElement;
4647
4843
  while (p) {
@@ -5084,7 +5280,7 @@ async function interactAndCollectLinks(page, origin) {
5084
5280
  const tabCount = await page.evaluate(
5085
5281
  () => document.querySelectorAll('[role="tab"]').length
5086
5282
  ).catch(() => 0);
5087
- for (let i = 0; i < Math.min(tabCount, 15); i++) {
5283
+ for (let i = 0; i < Math.min(tabCount, 30); i++) {
5088
5284
  try {
5089
5285
  const clicked = await page.evaluate((idx) => {
5090
5286
  const tabs = document.querySelectorAll('[role="tab"]');
@@ -5128,7 +5324,7 @@ async function interactAndCollectLinks(page, origin) {
5128
5324
  const accordionCount = await page.evaluate(
5129
5325
  () => document.querySelectorAll('details:not([open]) > summary, [data-state="closed"] > button, [aria-expanded="false"][class*="accordion" i]').length
5130
5326
  ).catch(() => 0);
5131
- for (let i = 0; i < Math.min(accordionCount, 10); i++) {
5327
+ for (let i = 0; i < Math.min(accordionCount, 20); i++) {
5132
5328
  try {
5133
5329
  await page.evaluate((idx) => {
5134
5330
  const items = document.querySelectorAll(
@@ -5166,7 +5362,7 @@ async function expandNavMenus(page) {
5166
5362
  while (p) {
5167
5363
  const tag = p.tagName?.toLowerCase();
5168
5364
  const role = p.getAttribute("role") ?? "";
5169
- if (["form", "table", "select"].includes(tag ?? "") || ["dialog", "alertdialog", "combobox", "listbox", "grid", "treegrid"].includes(role)) return true;
5365
+ if (["table", "select"].includes(tag ?? "") || ["dialog", "alertdialog", "combobox", "listbox", "grid", "treegrid"].includes(role)) return true;
5170
5366
  p = p.parentElement;
5171
5367
  }
5172
5368
  return false;
@@ -5196,7 +5392,7 @@ async function expandNavMenus(page) {
5196
5392
  const navHandles = await page.$$(
5197
5393
  'nav > ul > li, nav > li, header > ul > li, header > nav > ul > li, [role="navigation"] > ul > li, [role="menubar"] > [role="menuitem"], [class*="nav"] > ul > li, [class*="menu"] > li'
5198
5394
  ).catch(() => []);
5199
- for (const h of navHandles.slice(0, 20)) {
5395
+ for (const h of navHandles.slice(0, 40)) {
5200
5396
  try {
5201
5397
  await h.hover({ force: true, timeout: 600 });
5202
5398
  await page.waitForTimeout(280);
@@ -5245,6 +5441,10 @@ async function domFallbackLinks(page, origin, rawPage) {
5245
5441
  root.querySelectorAll("[data-href]").forEach((el) => {
5246
5442
  add(el.getAttribute("data-href") || "", el.textContent || "");
5247
5443
  });
5444
+ root.querySelectorAll("form[action]").forEach((form) => {
5445
+ const method = (form.getAttribute("method") || "get").toLowerCase();
5446
+ if (method !== "post") add(form.getAttribute("action") || "", form.getAttribute("aria-label") || "");
5447
+ });
5248
5448
  root.querySelectorAll("*").forEach((el) => {
5249
5449
  try {
5250
5450
  if (el.shadowRoot && !rootSeen.has(el.shadowRoot)) {
@@ -5652,6 +5852,21 @@ async function captureAuthDiscoveryScreen(page, opts, requestedUrl, source, ordi
5652
5852
  await AppBrain.saveScreenshot(opts.tenantId, opts.projectId, screen.id, shotPath, screen.version, "DESKTOP", authCloudUrl).catch(() => {
5653
5853
  });
5654
5854
  }
5855
+ const authDomContent = await capturePageHtml(page);
5856
+ if (authDomContent && opts.screenshotDir) {
5857
+ const authDomPath = shotPath.replace(/\.(png|jpg|jpeg)$/i, ".dom.html");
5858
+ await fs.writeFile(authDomPath, authDomContent, "utf8");
5859
+ let authDomCloudUrl;
5860
+ if (opts.storageConfig) {
5861
+ try {
5862
+ const { uploadScreenshotToTenantStorage } = await import("@detiq/app-brain");
5863
+ authDomCloudUrl = await uploadScreenshotToTenantStorage(opts.storageConfig, authDomPath, `${opts.tenantId}/${opts.projectId}/dom-snapshots/${path.basename(authDomPath)}`);
5864
+ } catch {
5865
+ }
5866
+ }
5867
+ await AppBrain.updateScreenDomSnapshot(opts.tenantId, screen.id, authDomPath, authDomCloudUrl).catch(() => {
5868
+ });
5869
+ }
5655
5870
  await AppBrain.saveCrawlEvidence(opts.tenantId, opts.projectId, {
5656
5871
  screenId: screen.id,
5657
5872
  jobId: opts.jobId,