@detiq/crawler 1.1.14 → 1.1.15

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.
Files changed (109) hide show
  1. package/Dockerfile +1 -1
  2. package/dist/accessibility-tree-crawler.js +156 -0
  3. package/dist/accessibility-tree-crawler.js.map +7 -0
  4. package/dist/advanced-ui-probes.js +310 -0
  5. package/dist/advanced-ui-probes.js.map +7 -0
  6. package/dist/content-chunker.js +2 -2
  7. package/dist/content-chunker.js.map +2 -2
  8. package/dist/crawl-session-manager.js +145 -0
  9. package/dist/crawl-session-manager.js.map +7 -0
  10. package/dist/crawler.js +1142 -263
  11. package/dist/crawler.js.map +2 -2
  12. package/dist/crud-interaction.js +264 -0
  13. package/dist/crud-interaction.js.map +7 -0
  14. package/dist/dropdown-enumerator.js +230 -0
  15. package/dist/dropdown-enumerator.js.map +7 -0
  16. package/dist/filter-pagination-crawler.js +299 -0
  17. package/dist/filter-pagination-crawler.js.map +7 -0
  18. package/dist/frontier.js +5 -3
  19. package/dist/frontier.js.map +2 -2
  20. package/dist/gap-pass.js +47 -14
  21. package/dist/gap-pass.js.map +2 -2
  22. package/dist/http-engine.js +28 -15
  23. package/dist/http-engine.js.map +2 -2
  24. package/dist/index.js +15 -1
  25. package/dist/index.js.map +2 -2
  26. package/dist/job-queue.js +12 -5
  27. package/dist/job-queue.js.map +2 -2
  28. package/dist/locator-extractor.js +23 -0
  29. package/dist/locator-extractor.js.map +2 -2
  30. package/dist/multi-context-handler.js +182 -0
  31. package/dist/multi-context-handler.js.map +7 -0
  32. package/dist/nested-window-crawler.js +172 -0
  33. package/dist/nested-window-crawler.js.map +7 -0
  34. package/dist/network-action-correlator.js +209 -0
  35. package/dist/network-action-correlator.js.map +7 -0
  36. package/dist/performance-metrics-collector.js +155 -0
  37. package/dist/performance-metrics-collector.js.map +7 -0
  38. package/dist/performance-metrics.js.map +2 -2
  39. package/dist/policies/crawl-safety.js +5 -1
  40. package/dist/policies/crawl-safety.js.map +2 -2
  41. package/dist/profiles/domain-profile.js +21 -1
  42. package/dist/profiles/domain-profile.js.map +2 -2
  43. package/dist/rate-limiter.js +2 -2
  44. package/dist/rate-limiter.js.map +2 -2
  45. package/dist/screen-reader.js +1 -0
  46. package/dist/screen-reader.js.map +2 -2
  47. package/dist/service-worker-interceptor.js +136 -0
  48. package/dist/service-worker-interceptor.js.map +7 -0
  49. package/dist/session-capture.js +4 -2
  50. package/dist/session-capture.js.map +2 -2
  51. package/dist/shadow-dom-crawler.js +132 -0
  52. package/dist/shadow-dom-crawler.js.map +7 -0
  53. package/dist/sitemap.js +16 -10
  54. package/dist/sitemap.js.map +2 -2
  55. package/dist/state-graph.js +193 -0
  56. package/dist/state-graph.js.map +7 -0
  57. package/dist/stealth/human-behavior.js +36 -3
  58. package/dist/stealth/human-behavior.js.map +2 -2
  59. package/dist/url-pattern-completer.js +48 -14
  60. package/dist/url-pattern-completer.js.map +2 -2
  61. package/dist/visual-diff-verdict.js +3 -1
  62. package/dist/visual-diff-verdict.js.map +2 -2
  63. package/package.json +2 -1
  64. package/src/accessibility-tree-crawler.ts +246 -0
  65. package/src/advanced-ui-probes.ts +470 -0
  66. package/src/crawl-session-manager.ts +231 -0
  67. package/src/crawler.ts +1050 -134
  68. package/src/crud-interaction.ts +386 -0
  69. package/src/dropdown-enumerator.ts +312 -0
  70. package/src/filter-pagination-crawler.ts +500 -0
  71. package/src/gap-pass.ts +58 -16
  72. package/src/index.ts +14 -1
  73. package/src/locator-extractor.ts +45 -0
  74. package/src/multi-context-handler.ts +296 -0
  75. package/src/nested-window-crawler.ts +310 -0
  76. package/src/network-action-correlator.ts +320 -0
  77. package/src/performance-metrics-collector.ts +243 -0
  78. package/src/performance-metrics.ts +1 -1
  79. package/src/screen-reader.ts +4 -1
  80. package/src/service-worker-interceptor.ts +185 -0
  81. package/src/session-capture.ts +3 -0
  82. package/src/shadow-dom-crawler.ts +198 -0
  83. package/src/state-graph.ts +280 -0
  84. package/src/stealth/human-behavior.ts +38 -4
  85. package/src/url-pattern-completer.ts +61 -15
  86. package/tsconfig.json +1 -1
  87. package/types/accessibility-tree-crawler.d.ts +44 -0
  88. package/types/advanced-ui-probes.d.ts +47 -0
  89. package/types/crawl-session-manager.d.ts +44 -0
  90. package/types/crawler.d.ts +3 -1
  91. package/types/crud-interaction.d.ts +31 -0
  92. package/types/dropdown-enumerator.d.ts +20 -0
  93. package/types/filter-pagination-crawler.d.ts +52 -0
  94. package/types/gap-pass.d.ts +1 -0
  95. package/types/index.d.ts +14 -1
  96. package/types/job-queue.d.ts +18 -9
  97. package/types/locator-extractor.d.ts +7 -0
  98. package/types/multi-context-handler.d.ts +55 -0
  99. package/types/nested-window-crawler.d.ts +41 -0
  100. package/types/network-action-correlator.d.ts +47 -0
  101. package/types/performance-metrics-collector.d.ts +50 -0
  102. package/types/performance-metrics.d.ts +1 -2
  103. package/types/screen-reader.d.ts +1 -2
  104. package/types/service-worker-interceptor.d.ts +36 -0
  105. package/types/session-capture.d.ts +1 -1
  106. package/types/shadow-dom-crawler.d.ts +35 -0
  107. package/types/sitemap.d.ts +2 -2
  108. package/types/state-graph.d.ts +59 -0
  109. package/types/stealth/human-behavior.d.ts +1 -1
package/dist/crawler.js CHANGED
@@ -50,6 +50,25 @@ import { screensToSitemapUrls, writeSitemap } from "./sitemap-writer.js";
50
50
  import { generateOpenApiSpec, specToYaml } from "./openapi-generator.js";
51
51
  import { WebhookNotifier } from "./webhook-notifier.js";
52
52
  import { generatePlaywrightTest } from "./playwright-recorder.js";
53
+ import { enumerateDropdowns } from "./dropdown-enumerator.js";
54
+ import { probeCrudFlows } from "./crud-interaction.js";
55
+ import { crawlFiltersAndPagination } from "./filter-pagination-crawler.js";
56
+ import { probeAdvancedUI } from "./advanced-ui-probes.js";
57
+ import { ScreenTransitionGraph } from "./state-graph.js";
58
+ import { crawlNestedContexts } from "./nested-window-crawler.js";
59
+ import { crawlShadowDom } from "./shadow-dom-crawler.js";
60
+ import { crawlAccessibilityTree } from "./accessibility-tree-crawler.js";
61
+ import { collectPerfMetrics } from "./performance-metrics.js";
62
+ import { inspectServiceWorker } from "./service-worker-interceptor.js";
63
+ async function getCrawlTuning() {
64
+ const { getSystemConfig } = await import("@detiq/core");
65
+ const cfg = await getSystemConfig("crawl_tuning");
66
+ return {
67
+ navTimeoutMs: parseInt(String(cfg?.navTimeoutMs ?? "15000"), 10),
68
+ defaultTimeoutMs: parseInt(String(cfg?.defaultTimeoutMs ?? "2000"), 10),
69
+ userAgent: String(cfg?.userAgent ?? "ZeTa-Crawler/1.0 (+https://zeta.taodigitalsolutions.com/crawler)")
70
+ };
71
+ }
53
72
  const _crawlLLMCache = /* @__PURE__ */ new Map();
54
73
  async function getCachedCrawlLLM(tenantId) {
55
74
  if (_crawlLLMCache.has(tenantId)) return _crawlLLMCache.get(tenantId);
@@ -83,20 +102,26 @@ function isGeneratedDynamicPath(pathname) {
83
102
  if (ALLOWED_NUMERIC_PATHS.has(normalized)) return false;
84
103
  return normalized.split("/").filter(Boolean).some(isGeneratedPathSegment);
85
104
  }
105
+ const DOWNLOAD_EXT_RE = /\.(pdf|zip|gz|tar|rar|7z|exe|dmg|pkg|deb|rpm|msi|apk|ipa|doc|docx|xls|xlsx|ppt|pptx|csv|json|xml|yaml|yml|txt|log|sql|db|sqlite|mp4|mp3|avi|mov|mkv|webm|wav|ogg|flac|jpg|jpeg|png|gif|bmp|svg|webp|ico|woff|woff2|ttf|eot|otf|bin|iso|img|vhd|vmdk)(\?.*)?$/i;
106
+ const API_PATH_RE = /^\/api\//i;
107
+ const EXPORT_PATH_RE = /\/(exports?|downloads?|export-csv|export-pdf|export-excel|download-report|generate-report|report-download)(\/|$|\?)/i;
86
108
  function isUsefulNavigationUrl(href, siteOrigin) {
87
109
  try {
88
110
  const u = new URL(href);
89
111
  if (!sameSite(u.toString(), siteOrigin)) return false;
90
112
  if (/\/(logout|logged-out|signout|sign-out)(\/|$|\?)/i.test(u.pathname)) return false;
91
113
  if (isGeneratedDynamicPath(u.pathname)) return false;
114
+ if (DOWNLOAD_EXT_RE.test(u.pathname)) return false;
115
+ if (API_PATH_RE.test(u.pathname)) return false;
116
+ if (EXPORT_PATH_RE.test(u.pathname)) return false;
92
117
  return true;
93
118
  } catch {
94
119
  return false;
95
120
  }
96
121
  }
97
- const AUTH_DISCOVERY_MAX_SCREENS = 36;
98
- const AUTH_DISCOVERY_MAX_QUEUE = 48;
99
- const AUTH_DISCOVERY_MAX_CLICKS_PER_PAGE = 6;
122
+ const AUTH_DISCOVERY_MAX_SCREENS = parseInt(process.env.CRAWLER_AUTH_DISCOVERY_MAX_SCREENS ?? "") || 36;
123
+ const AUTH_DISCOVERY_MAX_QUEUE = parseInt(process.env.CRAWLER_AUTH_DISCOVERY_MAX_QUEUE ?? "") || 48;
124
+ const AUTH_DISCOVERY_MAX_CLICKS_PER_PAGE = parseInt(process.env.CRAWLER_AUTH_DISCOVERY_MAX_CLICKS_PER_PAGE ?? "") || 6;
100
125
  const AUTH_DISCOVERY_PATHS = [
101
126
  "/",
102
127
  "/login",
@@ -322,6 +347,7 @@ const TwoFASchema = z.object({
322
347
  fieldLabel: z.string().optional()
323
348
  });
324
349
  async function crawlProject(opts, onProgress) {
350
+ const tuning = await getCrawlTuning();
325
351
  if (opts.screenshotDir) await fs.mkdir(opts.screenshotDir, { recursive: true });
326
352
  const uploadToCloud = async (localPath, key) => {
327
353
  if (!opts.storageConfig) return void 0;
@@ -367,15 +393,15 @@ async function crawlProject(opts, onProgress) {
367
393
  break;
368
394
  case "openrouter":
369
395
  stagehandModel = `openai/${m}`;
370
- stagehandModelOpts = { ...apiKey ? { apiKey } : {}, baseURL: baseUrl || "https://openrouter.ai/api/v1" };
396
+ stagehandModelOpts = { ...apiKey ? { apiKey } : {}, baseURL: baseUrl || process.env.CRAWLER_OPENROUTER_BASE_URL || "https://openrouter.ai/api/v1" };
371
397
  break;
372
398
  case "ollama":
373
399
  stagehandModel = `ollama/${m}`;
374
- stagehandModelOpts = { baseURL: baseUrl || "http://localhost:11434" };
400
+ stagehandModelOpts = { baseURL: baseUrl || process.env.CRAWLER_OLLAMA_BASE_URL || "http://localhost:11434" };
375
401
  break;
376
402
  case "nvidia":
377
403
  stagehandModel = `openai/${m.startsWith("openai/") ? m.slice(7) : m}`;
378
- stagehandModelOpts = { ...apiKey ? { apiKey } : {}, baseURL: baseUrl || "https://integrate.api.nvidia.com/v1" };
404
+ stagehandModelOpts = { ...apiKey ? { apiKey } : {}, baseURL: baseUrl || process.env.CRAWLER_NVIDIA_BASE_URL || "https://integrate.api.nvidia.com/v1" };
379
405
  break;
380
406
  case "groq":
381
407
  stagehandModel = `groq/${m}`;
@@ -422,15 +448,18 @@ async function crawlProject(opts, onProgress) {
422
448
  const preferAnthropic = envModel.includes("claude") || !envModel && !process.env.OPENAI_API_KEY;
423
449
  if (preferAnthropic && process.env.ANTHROPIC_API_KEY) {
424
450
  stagehandModelOpts = { apiKey: process.env.ANTHROPIC_API_KEY };
425
- stagehandModel = envModel.includes("/") ? envModel : `anthropic/${envModel || "claude-sonnet-4-6"}`;
451
+ const defaultAnthropicModel = process.env.CRAWLER_DEFAULT_MODEL ?? "claude-sonnet-4-6";
452
+ stagehandModel = envModel.includes("/") ? envModel : `anthropic/${envModel || defaultAnthropicModel}`;
426
453
  stagehandConfigured = true;
427
454
  } else if (process.env.OPENAI_API_KEY) {
428
455
  stagehandModelOpts = { apiKey: process.env.OPENAI_API_KEY };
429
- stagehandModel = envModel.includes("/") ? envModel : `openai/${envModel || "gpt-4o"}`;
456
+ const defaultOpenaiModel = process.env.CRAWLER_DEFAULT_MODEL ?? "gpt-4o";
457
+ stagehandModel = envModel.includes("/") ? envModel : `openai/${envModel || defaultOpenaiModel}`;
430
458
  stagehandConfigured = true;
431
459
  } else if (process.env.ANTHROPIC_API_KEY) {
432
460
  stagehandModelOpts = { apiKey: process.env.ANTHROPIC_API_KEY };
433
- stagehandModel = envModel.includes("/") ? envModel : `anthropic/${envModel || "claude-sonnet-4-6"}`;
461
+ const defaultAnthropicModel2 = process.env.CRAWLER_DEFAULT_MODEL ?? "claude-sonnet-4-6";
462
+ stagehandModel = envModel.includes("/") ? envModel : `anthropic/${envModel || defaultAnthropicModel2}`;
434
463
  stagehandConfigured = true;
435
464
  }
436
465
  }
@@ -464,7 +493,7 @@ async function crawlProject(opts, onProgress) {
464
493
  type: "CRAWL_PROFILE",
465
494
  payload: { profile: domainProfile, routeDecision }
466
495
  }).catch((err) => console.warn("[crawler] profile evidence save failed:", err?.message ?? err));
467
- if (!stagehandConfigured) {
496
+ if (!stagehandConfigured && !opts.discoveryMode) {
468
497
  throw new Error(
469
498
  configuredProvider ? `No API key configured for provider "${configuredProvider}". Go to Workspace \u2192 Settings \u2192 AI Provider.` : "No AI provider configured. Go to Workspace \u2192 Settings \u2192 AI Provider to set up an AI provider for browser crawling."
470
499
  );
@@ -607,17 +636,17 @@ async function crawlProject(opts, onProgress) {
607
636
  verbose: 1,
608
637
  logger: stagehandLogger,
609
638
  localBrowserLaunchOptions: { cdpUrl, headless: true },
610
- domSettleTimeout: 2e3
639
+ domSettleTimeout: parseInt(process.env.CRAWLER_DOM_SETTLE_TIMEOUT_MS ?? "") || 2e3
611
640
  });
612
641
  await stagehand.init();
613
642
  await onProgress?.(5, "Browser launched");
614
643
  const page = await stagehand.resolvePage();
615
644
  try {
616
- page.setDefaultTimeout(45e3);
645
+ page.setDefaultTimeout(tuning.defaultTimeoutMs || 45e3);
617
646
  } catch {
618
647
  }
619
648
  try {
620
- page.setDefaultNavigationTimeout(3e4);
649
+ page.setDefaultNavigationTimeout(tuning.navTimeoutMs || 3e4);
621
650
  } catch {
622
651
  }
623
652
  let rawPage = null;
@@ -707,11 +736,91 @@ async function crawlProject(opts, onProgress) {
707
736
  const pwContext = playwrightBrowser.contexts()[0];
708
737
  if (pwContext) {
709
738
  attachNetworkObserver(pwContext, capturedApiEndpoints, (relPath) => xhrDiscoveredPaths.add(relPath));
739
+ const OAUTH_PROVIDER_RE = /google\.|github\.|microsoft\.|facebook\.|twitter\.|linkedin\.|okta\.|auth0\.|ping\.|onelogin\.|sso\.|oauth\.|\/authorize\?|\/oauth2\//i;
710
740
  pwContext.on("page", (popup) => {
711
- const popupUrl = popup.url();
712
- logger.info({ url: popupUrl }, "[crawler] OAuth popup detected \u2014 waiting for close");
713
- popup.waitForEvent("close", { timeout: 12e4 }).then(() => logger.info("[crawler] OAuth popup closed \u2014 SSO flow complete")).catch(() => logger.warn("[crawler] OAuth popup timed out or errored"));
741
+ popup.waitForURL((u) => !u.startsWith("about:"), { timeout: 1e4 }).then(async () => {
742
+ const popupUrl = popup.url();
743
+ if (OAUTH_PROVIDER_RE.test(popupUrl)) {
744
+ logger.info({ url: popupUrl }, "[crawler] OAuth popup navigated to provider");
745
+ popup.waitForEvent("close", { timeout: 12e4 }).then(() => logger.info("[crawler] OAuth popup closed \u2014 SSO flow complete")).catch(() => logger.warn("[crawler] OAuth popup timed out or errored"));
746
+ return;
747
+ }
748
+ try {
749
+ const _popupPath = (() => {
750
+ try {
751
+ return new URL(popupUrl).pathname;
752
+ } catch {
753
+ return "";
754
+ }
755
+ })();
756
+ if (API_PATH_RE.test(_popupPath) || EXPORT_PATH_RE.test(_popupPath) || DOWNLOAD_EXT_RE.test(_popupPath)) return;
757
+ } catch {
758
+ }
759
+ logger.info({ url: popupUrl }, "[crawler] App popup detected \u2014 capturing");
760
+ try {
761
+ await popup.waitForLoadState("domcontentloaded", { timeout: 1e4 }).catch(() => {
762
+ });
763
+ await popup.waitForLoadState("networkidle", { timeout: 6e3 }).catch(() => {
764
+ });
765
+ const popupTitle = await popup.title().catch(() => "");
766
+ const popupName = popupTitle || (() => {
767
+ try {
768
+ return new URL(popupUrl).pathname.split("/").filter(Boolean).pop() || "Popup";
769
+ } catch {
770
+ return "Popup";
771
+ }
772
+ })();
773
+ const popupHash = popupUrl.replace(/[^a-z0-9]/gi, "").slice(-12);
774
+ const popupShotPath = path.join(opts.screenshotDir ?? "/tmp", `popup-${popupHash}.png`);
775
+ await waitForScreenshotReady(popup, popup);
776
+ await popup.screenshot({ path: popupShotPath, fullPage: false }).catch(() => {
777
+ });
778
+ const popupScreen = await AppBrain.saveScreen(opts.tenantId, opts.projectId, popupName, popupUrl, opts.jobId, "LINK_FOLLOW").catch(() => null);
779
+ if (popupScreen) {
780
+ const cloudUrl = await uploadToCloud(popupShotPath, `${opts.tenantId}/${opts.projectId}/screenshots/${path.basename(popupShotPath)}`).catch(() => void 0);
781
+ await AppBrain.saveScreenshot(opts.tenantId, opts.projectId, popupScreen.id, popupShotPath, popupScreen.version, "DESKTOP", cloudUrl).catch(() => {
782
+ });
783
+ const popupEls = await domFallbackElements(popup, popup).catch(() => []);
784
+ for (const el of popupEls) {
785
+ 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(() => {
786
+ });
787
+ }
788
+ logger.info({ url: popupUrl, name: popupName, elementCount: popupEls.length }, "[crawler] App popup screen saved");
789
+ }
790
+ } catch (popupErr) {
791
+ logger.warn({ url: popupUrl, err: popupErr?.message }, "[crawler] App popup capture failed \u2014 non-fatal");
792
+ }
793
+ }).catch(() => {
794
+ });
795
+ });
796
+ pwContext.on("download", (dl) => {
797
+ dl.cancel().catch(() => {
798
+ });
714
799
  });
800
+ try {
801
+ await pwContext.route("**/*", async (route, request) => {
802
+ const resourceType = request.resourceType?.() ?? "";
803
+ if (resourceType !== "document") {
804
+ await route.continue().catch(() => {
805
+ });
806
+ return;
807
+ }
808
+ try {
809
+ const u = new URL(request.url());
810
+ if (API_PATH_RE.test(u.pathname) || EXPORT_PATH_RE.test(u.pathname) || DOWNLOAD_EXT_RE.test(u.pathname)) {
811
+ logger.info({ url: request.url() }, "[crawler] route: aborting API/export/download navigation");
812
+ await route.abort("failed").catch(() => {
813
+ });
814
+ return;
815
+ }
816
+ } catch {
817
+ }
818
+ await route.continue().catch(() => {
819
+ });
820
+ });
821
+ } catch (routeErr) {
822
+ logger.warn({ err: routeErr?.message }, "[crawler] route interception setup failed \u2014 downloads may occur");
823
+ }
715
824
  }
716
825
  const pwContext2 = playwrightBrowser?.contexts()?.[0];
717
826
  if (pwContext2) {
@@ -799,6 +908,78 @@ async function crawlProject(opts, onProgress) {
799
908
  const homeFingerprintElemCount = await page.evaluate(
800
909
  () => document.querySelectorAll('button,a,input,select,textarea,[role="button"]').length
801
910
  ).catch(() => -1);
911
+ const preAuthPublicCaptured = /* @__PURE__ */ new Set();
912
+ if (!opts.discoveryMode) {
913
+ const _appOrigin = new URL(opts.appUrl).origin;
914
+ const _norm = (u) => {
915
+ try {
916
+ const p = new URL(u);
917
+ p.hash = "";
918
+ return p.href.replace(/\/$/, "");
919
+ } catch {
920
+ return u;
921
+ }
922
+ };
923
+ const _startNorm = _norm(opts.appUrl);
924
+ const _authPathRe = /\/(sign.?in|sign.?up|log.?in|register|auth|oauth|sso|forgot.?password|reset.?password)(\/|$|\?)/i;
925
+ const _skipPathRe = /\/(terms|privacy|legal|cookie-policy|changelog|blog|imprint)([-/]|$)/i;
926
+ const publicNavLinks = await page.evaluate((originStr) => {
927
+ return Array.from(document.querySelectorAll("a[href]")).map((a) => {
928
+ try {
929
+ const u = new URL(a.getAttribute("href"), window.location.href);
930
+ return u.origin === originStr ? u.href.split("#")[0] : "";
931
+ } catch {
932
+ return "";
933
+ }
934
+ }).filter((h, i, arr) => h && arr.indexOf(h) === i).slice(0, 30);
935
+ }, _appOrigin).catch(() => []);
936
+ for (const link of publicNavLinks) {
937
+ if (_norm(link) === _startNorm) continue;
938
+ try {
939
+ const { pathname } = new URL(link);
940
+ if (_authPathRe.test(pathname) || _skipPathRe.test(pathname) || API_PATH_RE.test(pathname) || EXPORT_PATH_RE.test(pathname) || DOWNLOAD_EXT_RE.test(pathname)) continue;
941
+ await page.goto(link, { waitUntil: "domcontentloaded", timeout: 15e3 }).catch(() => {
942
+ });
943
+ await page.waitForLoadState("networkidle", { timeout: 5e3 }).catch(() => {
944
+ });
945
+ const finalUrl = page.url();
946
+ const finalPath = new URL(finalUrl).pathname;
947
+ if (_authPathRe.test(finalPath)) continue;
948
+ if (_norm(finalUrl) === _startNorm) continue;
949
+ const preAuthElements = await readScreen(page).catch(() => []);
950
+ if (preAuthElements.length === 0) {
951
+ logger.info({ url: finalUrl }, "[crawler] pre-auth page has 0 elements \u2014 deferring to authenticated BFS");
952
+ continue;
953
+ }
954
+ const pathParts = finalPath.split("/").filter(Boolean);
955
+ const screenName = pathParts.map((p) => p.charAt(0).toUpperCase() + p.slice(1).replace(/[-_]/g, " ")).join(" \u2014 ") || "Home";
956
+ const screen = await AppBrain.saveScreen(opts.tenantId, opts.projectId, screenName, finalUrl, opts.jobId, "LINK_FOLLOW").catch(() => null);
957
+ if (!screen) continue;
958
+ const shotPath = path.join(opts.screenshotDir ?? "/tmp", `${screen.id}-desktop-v${screen.version}.png`);
959
+ await page.screenshot({ path: shotPath, fullPage: true }).catch(() => {
960
+ });
961
+ const cloudUrl = await uploadToCloud(shotPath, `${opts.tenantId}/${opts.projectId}/screenshots/${path.basename(shotPath)}`).catch(() => void 0);
962
+ await AppBrain.saveScreenshot(opts.tenantId, opts.projectId, screen.id, shotPath, screen.version, "DESKTOP", cloudUrl).catch(() => {
963
+ });
964
+ await AppBrain.clearScreenElements(opts.tenantId, opts.projectId, screen.id).catch(() => {
965
+ });
966
+ for (const el of preAuthElements) {
967
+ await AppBrain.saveElement(opts.tenantId, opts.projectId, screen.id, el.label, el.role, el.expectedData, void 0, void 0, true, void 0, void 0, void 0).catch(() => {
968
+ });
969
+ }
970
+ preAuthPublicCaptured.add(finalUrl);
971
+ logger.info({ url: finalUrl, screenId: screen.id, elements: preAuthElements.length }, "[crawler] pre-auth public page captured");
972
+ } catch (err) {
973
+ logger.warn({ url: link, err: err?.message }, "[crawler] pre-auth public page visit failed \u2014 skipping");
974
+ }
975
+ }
976
+ if (preAuthPublicCaptured.size > 0) {
977
+ await page.goto(opts.appUrl, { waitUntil: "domcontentloaded", timeout: 2e4 }).catch(() => {
978
+ });
979
+ await page.waitForLoadState("networkidle", { timeout: 8e3 }).catch(() => {
980
+ });
981
+ }
982
+ }
802
983
  if (!opts.discoveryMode) {
803
984
  try {
804
985
  await runAuthDiscoveryPhase(page, opts, new URL(opts.appUrl).origin, onProgress);
@@ -806,6 +987,16 @@ async function crawlProject(opts, onProgress) {
806
987
  logger.warn({ err: preAuthErr }, "[crawler] auth-discovery phase failed \u2014 continuing with authenticated crawl");
807
988
  }
808
989
  }
990
+ {
991
+ const worldOk = await page.evaluate(() => true).catch(() => false);
992
+ if (!worldOk) {
993
+ logger.warn("[crawler] isolated world corrupted after auth discovery \u2014 re-navigating to recover");
994
+ await page.goto(opts.appUrl, { waitUntil: "domcontentloaded", timeout: 15e3 }).catch(() => {
995
+ });
996
+ await page.waitForLoadState("networkidle", { timeout: 6e3 }).catch(() => {
997
+ });
998
+ }
999
+ }
809
1000
  let didLogin = false;
810
1001
  if (opts.authToken) {
811
1002
  await onProgress?.(18, "API token configured \u2014 skipping login");
@@ -819,8 +1010,42 @@ async function crawlProject(opts, onProgress) {
819
1010
  if (browserCtx) {
820
1011
  await browserCtx.addCookies(state.cookies);
821
1012
  }
1013
+ try {
1014
+ const stagehandCtx = page.context?.();
1015
+ if (stagehandCtx && typeof stagehandCtx.addCookies === "function") {
1016
+ await stagehandCtx.addCookies(state.cookies);
1017
+ }
1018
+ } catch {
1019
+ }
822
1020
  }
823
1021
  if (state.origins?.length) {
1022
+ const originCtx = playwrightBrowser?.contexts()[0];
1023
+ if (originCtx) {
1024
+ await originCtx.addInitScript((origins) => {
1025
+ for (const entry of origins ?? []) {
1026
+ if (window.location.origin === entry.origin) {
1027
+ for (const item of entry.localStorage) {
1028
+ try {
1029
+ localStorage.setItem(item.name, item.value);
1030
+ } catch {
1031
+ }
1032
+ }
1033
+ }
1034
+ }
1035
+ }, state.origins);
1036
+ }
1037
+ await page.addInitScript((origins) => {
1038
+ for (const entry of origins ?? []) {
1039
+ if (window.location.origin === entry.origin) {
1040
+ for (const item of entry.localStorage) {
1041
+ try {
1042
+ localStorage.setItem(item.name, item.value);
1043
+ } catch {
1044
+ }
1045
+ }
1046
+ }
1047
+ }
1048
+ }, state.origins);
824
1049
  await rawPage.addInitScript((origins) => {
825
1050
  for (const entry of origins ?? []) {
826
1051
  if (window.location.origin === entry.origin) {
@@ -1024,12 +1249,43 @@ async function crawlProject(opts, onProgress) {
1024
1249
  "nonce",
1025
1250
  "session",
1026
1251
  "sid",
1252
+ // App content/state params — these change the content rendered within the same
1253
+ // page template (a playground example, a search query, a tab selection) but do NOT
1254
+ // represent structurally distinct pages. Not stripping them causes URL explosion:
1255
+ // every unique ?cmd=DSN-XXXXX&example=... or ?q=foo is queued as a new screen.
1256
+ "cmd",
1257
+ "example",
1258
+ "query",
1259
+ "q",
1260
+ "search",
1261
+ "keyword",
1262
+ "keywords",
1263
+ "tab",
1264
+ "view",
1265
+ "mode",
1266
+ "sort",
1267
+ "order",
1268
+ "direction",
1269
+ "orderby",
1270
+ "page",
1271
+ "p",
1272
+ "offset",
1273
+ "limit",
1274
+ "per_page",
1275
+ "size",
1276
+ "rows",
1277
+ // Export format params — same endpoint, different output format, not a new screen
1278
+ "format",
1279
+ "output",
1280
+ "export_format",
1281
+ "download_format",
1282
+ "fileformat",
1283
+ "file_format",
1027
1284
  // WooCommerce / e-commerce action params
1028
1285
  "add-to-cart",
1029
1286
  "add_to_wishlist",
1030
1287
  "quantity",
1031
1288
  "wc-ajax",
1032
- "orderby",
1033
1289
  "min_price",
1034
1290
  "max_price",
1035
1291
  "removed_item",
@@ -1097,9 +1353,11 @@ async function crawlProject(opts, onProgress) {
1097
1353
  };
1098
1354
  const gitInfo = getGitInfo();
1099
1355
  const visited = /* @__PURE__ */ new Map();
1356
+ const crawlWarnings = [];
1100
1357
  if (opts.skipUrls?.length) {
1101
1358
  for (const u of opts.skipUrls) visited.set(canonicalize(u), "skipped");
1102
1359
  }
1360
+ for (const u of preAuthPublicCaptured) visited.set(canonicalize(u), "pre-auth");
1103
1361
  let checkpointSuccessCount = 0;
1104
1362
  let maxSeenTotal = 0;
1105
1363
  let lastEmittedPct = 20;
@@ -1133,7 +1391,7 @@ async function crawlProject(opts, onProgress) {
1133
1391
  ];
1134
1392
  const queued = new Set(seedUrls.map(canonicalize));
1135
1393
  const queue = new CrawlFrontier(opts.traversalStrategy ?? "BFS", seedUrls, opts.bestFirstKeywords, opts.urlFilter);
1136
- const deduplicator = new ContentDeduplicator(0.88);
1394
+ const deduplicator = new ContentDeduplicator(parseFloat(process.env.CRAWLER_DEDUP_THRESHOLD ?? "") || 0.88);
1137
1395
  const deadLinkTracker = new DeadLinkTracker();
1138
1396
  const redirectRegistry = new RedirectRegistry();
1139
1397
  if (!selective && resumeUrls.length === 0) {
@@ -1143,6 +1401,7 @@ async function crawlProject(opts, onProgress) {
1143
1401
  const orphanIdSet = new Set(
1144
1402
  (orphanReport?.screens ?? []).filter((s) => s.classification !== "REACHABLE").map((s) => s.id)
1145
1403
  );
1404
+ const seenReseedCanon = new Set(queued);
1146
1405
  const priorScreens = (priorMap?.screens ?? []).filter((s) => s.status !== "PENDING_REMOVAL").filter((s) => {
1147
1406
  const u = s.url;
1148
1407
  if (!u) return false;
@@ -1151,11 +1410,15 @@ async function crawlProject(opts, onProgress) {
1151
1410
  const stripWww = (h) => h.replace(/^www\./i, "").toLowerCase();
1152
1411
  if (stripWww(new URL(u).hostname) !== stripWww(new URL(startUrl).hostname)) return false;
1153
1412
  if (isGeneratedDynamicPath(pathname)) return false;
1413
+ if (!isUsefulNavigationUrl(u, startUrl)) return false;
1414
+ const canon = canonicalize(u);
1415
+ if (seenReseedCanon.has(canon)) return false;
1416
+ seenReseedCanon.add(canon);
1154
1417
  return true;
1155
1418
  } catch {
1156
1419
  return false;
1157
1420
  }
1158
- }).filter((s) => !queued.has(canonicalize(s.url)));
1421
+ });
1159
1422
  let orphanSeedCount = 0;
1160
1423
  for (const s of priorScreens) {
1161
1424
  const u = s.url;
@@ -1213,6 +1476,7 @@ async function crawlProject(opts, onProgress) {
1213
1476
  try {
1214
1477
  const u = new URL(sUrl);
1215
1478
  if (u.origin !== sitemapOrigin) continue;
1479
+ if (API_PATH_RE.test(u.pathname) || EXPORT_PATH_RE.test(u.pathname) || DOWNLOAD_EXT_RE.test(u.pathname)) continue;
1216
1480
  await page.goto(sUrl, { waitUntil: "domcontentloaded", timeout: 12e3 });
1217
1481
  await page.waitForLoadState("networkidle", { timeout: 5e3 }).catch(() => {
1218
1482
  });
@@ -1268,16 +1532,20 @@ async function crawlProject(opts, onProgress) {
1268
1532
  const discovered = [];
1269
1533
  const performanceMetrics = [];
1270
1534
  const seenDomHashes = new Set(opts.knownDomHashes ?? []);
1271
- const SKIP_PATH = /\/(terms|privacy|dpa|legal|cookie-policy|changelog|blog|imprint|gdpr|compliance|acceptable-use|security-policy|press|sitemap)([-/]|$)/i;
1535
+ const skipPathExtra = process.env.CRAWL_SKIP_PATH_EXTRA ? process.env.CRAWL_SKIP_PATH_EXTRA.split(",").map((p) => p.trim()).filter(Boolean) : [];
1536
+ const SKIP_PATH = skipPathExtra.length > 0 ? new RegExp(`/(terms|privacy|dpa|legal|cookie-policy|changelog|blog|imprint|gdpr|compliance|acceptable-use|security-policy|press|sitemap|${skipPathExtra.join("|")})([-/]|$)`, "i") : /\/(terms|privacy|dpa|legal|cookie-policy|changelog|blog|imprint|gdpr|compliance|acceptable-use|security-policy|press|sitemap)([-/]|$)/i;
1272
1537
  const segIdx = opts.segmentIndex ?? 1;
1273
1538
  const robotsCache = opts.respectRobotsTxt ? new RobotsCache() : null;
1274
1539
  let avgNavMs = 0;
1275
1540
  let navCount = 0;
1276
1541
  let aiErrorStreak = 0;
1277
1542
  let aiSkipCount = 0;
1543
+ const aiMinElements = parseInt(process.env.CRAWL_AI_LINK_MIN_ELEMENTS ?? "") || 5;
1278
1544
  let aiRateLimitWarned = false;
1279
1545
  let totalAttemptsThisSegment = 0;
1280
1546
  const maxAttempts = Math.max(segmentSize * 4, 200);
1547
+ let loginWallRedirectCount = 0;
1548
+ const LOGIN_WALL_ABORT_THRESHOLD = Math.max(8, Math.ceil(segmentSize * 0.4));
1281
1549
  let pendingSessionRestore = null;
1282
1550
  const CRAWL_PAGE_PARALLELISM = Math.max(1, parseInt(process.env.CRAWL_PAGE_PARALLELISM ?? "5", 10));
1283
1551
  async function runLiteWorker(workerPage) {
@@ -1291,18 +1559,42 @@ async function crawlProject(opts, onProgress) {
1291
1559
  try {
1292
1560
  const { pathname } = new URL(url);
1293
1561
  if (SKIP_PATH.test(pathname) || isGeneratedDynamicPath(pathname)) continue;
1562
+ if (API_PATH_RE.test(pathname) || EXPORT_PATH_RE.test(pathname) || DOWNLOAD_EXT_RE.test(pathname)) continue;
1294
1563
  } catch {
1295
1564
  continue;
1296
1565
  }
1297
1566
  visited.set(canonUrl, "_pending_");
1298
1567
  try {
1299
- const navErr = await workerPage.goto(url, { waitUntil: "domcontentloaded", timeout: 15e3 }).then(() => null, (e) => e);
1568
+ const navErr = await workerPage.goto(url, { waitUntil: "domcontentloaded", timeout: tuning.navTimeoutMs }).then(() => null, (e) => e);
1300
1569
  if (navErr && /timeout/i.test(String(navErr?.message ?? navErr))) {
1301
1570
  await workerPage.goto(url, { waitUntil: "domcontentloaded", timeout: 3e4 }).catch(() => {
1302
1571
  });
1303
1572
  }
1304
1573
  await workerPage.waitForLoadState("networkidle", { timeout: 8e3 }).catch(() => {
1305
1574
  });
1575
+ await waitForStableDOM(workerPage, 6e3, 400).catch(() => {
1576
+ });
1577
+ await dismissOverlays(workerPage).catch(() => {
1578
+ });
1579
+ const lwCurrentUrl = workerPage.url();
1580
+ const lwPath = (() => {
1581
+ try {
1582
+ return new URL(lwCurrentUrl).pathname.toLowerCase();
1583
+ } catch {
1584
+ return "";
1585
+ }
1586
+ })();
1587
+ const lwIsAuthWall = /^\/(login|signin|sign-in|auth|authenticate|session\/new|account\/login|user\/login)/.test(lwPath) && lwCurrentUrl !== url;
1588
+ const lwHasLoginForm = lwIsAuthWall ? false : await workerPage.evaluate(() => {
1589
+ const inputs = document.querySelectorAll('input[type="password"]');
1590
+ return inputs.length > 0;
1591
+ }).catch(() => false);
1592
+ if (lwIsAuthWall) {
1593
+ logger.warn({ url, landed: lwCurrentUrl }, "[lite-worker] auth wall detected \u2014 queuing for main worker re-auth");
1594
+ visited.set(canonUrl, "_auth_wall_");
1595
+ if (!revisitQueue.includes(url)) revisitQueue.push(url);
1596
+ continue;
1597
+ }
1306
1598
  if (homeFingerprintElemCount >= 0 && url !== startUrl) {
1307
1599
  const wTitle = await workerPage.title().catch(() => "");
1308
1600
  if (wTitle === homeFingerprintTitle && wTitle !== "") {
@@ -1321,31 +1613,169 @@ async function crawlProject(opts, onProgress) {
1321
1613
  const screen = await AppBrain.saveScreen(opts.tenantId, opts.projectId, name, url, opts.jobId, discoveryMethodFor(url));
1322
1614
  visited.set(canonUrl, screen.id);
1323
1615
  const shotPath = path.join(opts.screenshotDir ?? "/tmp", `${screen.id}-desktop-v${screen.version}.png`);
1616
+ let liteElements = await domFallbackElements(workerPage).catch(() => []);
1617
+ if (liteElements.length === 0) {
1618
+ liteElements = await workerPage.evaluate(() => {
1619
+ const sel = 'button,a[href],input,select,textarea,[role],[tabindex="0"],[onclick],[data-action]';
1620
+ return Array.from(document.querySelectorAll(sel)).filter((el) => el.offsetParent !== null).slice(0, 150).map((el) => ({
1621
+ meaning: el.getAttribute("aria-label") || el.textContent?.trim().slice(0, 100) || el.tagName.toLowerCase(),
1622
+ role: el.getAttribute("role") || el.tagName.toLowerCase(),
1623
+ expectedData: null,
1624
+ notes: null,
1625
+ boundingRect: null,
1626
+ ariaState: null,
1627
+ parentLandmark: null
1628
+ }));
1629
+ }).catch(() => []);
1630
+ }
1324
1631
  const [elements] = await Promise.all([
1325
- domFallbackElements(workerPage).catch(() => []),
1632
+ Promise.resolve(liteElements),
1326
1633
  workerPage.screenshot({ path: shotPath, fullPage: true }).then(async () => {
1327
1634
  const cloudUrl = await uploadToCloud(shotPath, `${opts.tenantId}/${opts.projectId}/screenshots/${path.basename(shotPath)}`);
1328
1635
  return AppBrain.saveScreenshot(opts.tenantId, opts.projectId, screen.id, shotPath, screen.version, "DESKTOP", cloudUrl);
1329
1636
  }).catch(() => {
1330
1637
  })
1331
1638
  ]);
1639
+ const _navEls = await workerPage.evaluate(() => {
1640
+ if (typeof globalThis.__name === "undefined") globalThis.__name = (fn) => fn;
1641
+ const seen = /* @__PURE__ */ new Set();
1642
+ const items = [];
1643
+ function isV(el) {
1644
+ const s = window.getComputedStyle(el);
1645
+ return s.display !== "none" && s.visibility !== "hidden" && s.opacity !== "0";
1646
+ }
1647
+ document.querySelectorAll('nav a, [role="navigation"] a, header a, footer nav a, [aria-label*="navigation" i] a').forEach((a) => {
1648
+ if (!isV(a)) return;
1649
+ const label = (a.getAttribute("aria-label") || a.textContent?.replace(/\s+/g, " ").trim() || "").slice(0, 60);
1650
+ const href = a.href ?? "";
1651
+ if (!label || !href || /^(javascript:|#$)/i.test(href)) return;
1652
+ let landmark = "Site Navigation";
1653
+ let cur = a.parentElement;
1654
+ while (cur) {
1655
+ const t = cur.tagName?.toLowerCase();
1656
+ const r = cur.getAttribute?.("role")?.toLowerCase();
1657
+ if (t === "header" || r === "banner") {
1658
+ landmark = "Header";
1659
+ break;
1660
+ }
1661
+ if (t === "footer" || r === "contentinfo") {
1662
+ landmark = "Footer";
1663
+ break;
1664
+ }
1665
+ if (t === "nav" || r === "navigation") {
1666
+ landmark = cur.getAttribute("aria-label")?.trim() || "Site Navigation";
1667
+ break;
1668
+ }
1669
+ cur = cur.parentElement;
1670
+ }
1671
+ const key = `nav|${label.toLowerCase()}|${href}`;
1672
+ if (!seen.has(key)) {
1673
+ seen.add(key);
1674
+ items.push({ meaning: label, role: "nav-link", notes: JSON.stringify({ href: href.slice(0, 150), landmark }) });
1675
+ }
1676
+ });
1677
+ let fc = 0;
1678
+ document.querySelectorAll('footer a, [role="contentinfo"] a, [class*="footer" i] a').forEach((a) => {
1679
+ if (fc >= 20 || !isV(a)) return;
1680
+ const label = (a.getAttribute("aria-label") || a.textContent?.replace(/\s+/g, " ").trim() || "").slice(0, 60);
1681
+ const href = a.href ?? "";
1682
+ if (!label || !href) return;
1683
+ const key = `footer|${label.toLowerCase()}|${href}`;
1684
+ if (!seen.has(key)) {
1685
+ seen.add(key);
1686
+ items.push({ meaning: label, role: "footer-link", notes: JSON.stringify({ href: href.slice(0, 150) }) });
1687
+ fc++;
1688
+ }
1689
+ });
1690
+ return items;
1691
+ }).catch(() => []);
1692
+ const allElements = [
1693
+ ...elements,
1694
+ ..._navEls.map((ne) => ({ ...ne, expectedData: void 0, boundingRect: void 0, ariaState: void 0, parentLandmark: void 0 }))
1695
+ ];
1696
+ if (allElements.length > 0) {
1697
+ await AppBrain.clearScreenElements(opts.tenantId, opts.projectId, screen.id).catch(() => {
1698
+ });
1699
+ for (let _ei = 0; _ei < allElements.length; _ei += 4) {
1700
+ const _batch = allElements.slice(_ei, _ei + 4);
1701
+ await Promise.all(_batch.map(
1702
+ (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(() => {
1703
+ })
1704
+ ));
1705
+ }
1706
+ logger.info({ url, screenId: screen.id, elements: elements.length, navLinks: _navEls.length }, "[lite-worker] screen captured");
1707
+ } else if (!revisitQueue.includes(url)) {
1708
+ logger.warn({ url, screenId: screen.id }, "[lite-worker] 0 elements \u2014 queued for P3a revisit");
1709
+ revisitQueue.push(url);
1710
+ }
1711
+ if (screen?.id) {
1712
+ const [shadowRes, a11yRes, swRes] = await Promise.race([
1713
+ Promise.allSettled([
1714
+ crawlShadowDom(workerPage),
1715
+ crawlAccessibilityTree(workerPage),
1716
+ inspectServiceWorker(workerPage)
1717
+ ]),
1718
+ new Promise(
1719
+ (resolve) => setTimeout(() => resolve([
1720
+ { status: "rejected", reason: "timeout" },
1721
+ { status: "rejected", reason: "timeout" },
1722
+ { status: "rejected", reason: "timeout" }
1723
+ ]), 15e3)
1724
+ )
1725
+ ]);
1726
+ const litePayload = { screenId: screen.id, url };
1727
+ if (shadowRes.status === "fulfilled" && shadowRes.value && shadowRes.value.shadowHostCount > 0)
1728
+ litePayload.shadowDom = shadowRes.value;
1729
+ if (a11yRes.status === "fulfilled" && a11yRes.value)
1730
+ litePayload.accessibilityTree = a11yRes.value;
1731
+ if (swRes.status === "fulfilled" && swRes.value.supported)
1732
+ litePayload.serviceWorker = swRes.value;
1733
+ if (Object.keys(litePayload).length > 2) {
1734
+ AppBrain.saveCrawlEvidence(opts.tenantId, opts.projectId, {
1735
+ jobId: opts.jobId,
1736
+ type: "PER_SCREEN_PROBES",
1737
+ payload: litePayload
1738
+ }).catch(() => {
1739
+ });
1740
+ }
1741
+ }
1332
1742
  const nextLinks = await workerPage.evaluate((originStr) => {
1333
- return Array.from(document.querySelectorAll("a[href]")).map((a) => {
1743
+ return Array.from(document.querySelectorAll("a[href],[data-href]")).map((a) => {
1744
+ const raw = a.getAttribute("href") || a.getAttribute("data-href") || "";
1334
1745
  try {
1335
- return new URL(a.getAttribute("href"), window.location.href).href;
1746
+ return new URL(raw, window.location.href).href;
1336
1747
  } catch {
1337
1748
  return "";
1338
1749
  }
1339
- }).filter((h) => h && new URL(h).origin === originStr && !h.includes("#")).slice(0, 200);
1750
+ }).filter((h) => {
1751
+ if (!h) return false;
1752
+ try {
1753
+ const u = new URL(h);
1754
+ if (u.origin !== originStr) return false;
1755
+ if (u.hash && !u.hash.startsWith("#/") && !u.hash.startsWith("#!/")) return false;
1756
+ return true;
1757
+ } catch {
1758
+ return false;
1759
+ }
1760
+ }).slice(0, 200);
1340
1761
  }, origin).catch(() => []);
1341
1762
  for (const link of nextLinks) {
1763
+ if (!isUsefulNavigationUrl(link, origin)) continue;
1342
1764
  const cLink = canonicalize(link);
1343
- if (!visited.has(cLink)) {
1765
+ if (!visited.has(cLink) && !queued.has(cLink)) {
1766
+ queued.add(cLink);
1344
1767
  queue.push(link);
1345
1768
  edges.push({ fromUrl: url, toUrl: link });
1346
1769
  }
1347
1770
  }
1348
1771
  discovered.push({ url, screenId: screen.id, elements: elements.length, elementFacts: elements, screenshotPath: shotPath, version: screen.version });
1772
+ const lwEffMax = isFinite(maxScreens) ? Math.min(visited.size + queue.length, maxScreens * 3) : visited.size + queue.length;
1773
+ maxSeenTotal = Math.max(maxSeenTotal, lwEffMax);
1774
+ const lwRawPct = 20 + Math.round(Math.min(57, visited.size / Math.max(1, maxSeenTotal) * 57));
1775
+ const lwPct = Math.max(lastEmittedPct, lwRawPct);
1776
+ lastEmittedPct = lwPct;
1777
+ await onProgress?.(lwPct, `Crawling screens (${visited.size} mapped, ${queue.length} pending)`).catch(() => {
1778
+ });
1349
1779
  } catch (err) {
1350
1780
  logger.warn({ url, err: err?.message ?? String(err) }, "[lite-worker] URL processing failed");
1351
1781
  discovered.push({ url, error: err?.message ?? String(err) });
@@ -1353,7 +1783,7 @@ async function crawlProject(opts, onProgress) {
1353
1783
  }
1354
1784
  }
1355
1785
  const liteWorkerPromises = [];
1356
- if (CRAWL_PAGE_PARALLELISM > 1 && playwrightBrowser) {
1786
+ if (CRAWL_PAGE_PARALLELISM > 1 && playwrightBrowser && !opts.discoveryMode) {
1357
1787
  const pwCtx = playwrightBrowser.contexts()[0];
1358
1788
  for (let i = 1; i < CRAWL_PAGE_PARALLELISM; i++) {
1359
1789
  const wp = await pwCtx.newPage();
@@ -1361,7 +1791,7 @@ async function crawlProject(opts, onProgress) {
1361
1791
  Object.defineProperty(navigator, "webdriver", { get: () => void 0 });
1362
1792
  }).catch(() => {
1363
1793
  });
1364
- wp.setDefaultTimeout(45e3);
1794
+ wp.setDefaultTimeout(tuning.defaultTimeoutMs || 45e3);
1365
1795
  liteWorkerPromises.push(runLiteWorker(wp).finally(() => wp.close().catch(() => {
1366
1796
  })));
1367
1797
  }
@@ -1386,9 +1816,10 @@ async function crawlProject(opts, onProgress) {
1386
1816
  }
1387
1817
  const url = queue.shift();
1388
1818
  const canonUrl = canonicalize(url);
1389
- logger.info({ url, visited: visited.size, queued: queue.length, segment: `${visited.size + 1}/${segmentSize}` }, "[crawler] visiting URL");
1390
1819
  const apiObservationStartIndex = capturedApiEndpoints.length;
1391
1820
  if (visited.has(canonUrl)) continue;
1821
+ visited.set(canonUrl, "_pending_");
1822
+ logger.info({ url, visited: visited.size, queued: queue.length, segment: `${visited.size}/${segmentSize}` }, "[crawler] visiting URL");
1392
1823
  if (robotsCache && !await robotsCache.isAllowed(url)) {
1393
1824
  logger.info({ url }, "[crawler] robots.txt: skipping disallowed URL");
1394
1825
  continue;
@@ -1403,6 +1834,11 @@ async function crawlProject(opts, onProgress) {
1403
1834
  console.log(`[crawler] skip generated dynamic route: ${url}`);
1404
1835
  continue;
1405
1836
  }
1837
+ if (API_PATH_RE.test(pathname) || EXPORT_PATH_RE.test(pathname) || DOWNLOAD_EXT_RE.test(pathname)) {
1838
+ console.log(`[crawler] skip API/export/download path: ${url}`);
1839
+ visited.set(canonUrl, "_skipped_api_");
1840
+ continue;
1841
+ }
1406
1842
  } catch {
1407
1843
  }
1408
1844
  try {
@@ -1481,7 +1917,7 @@ async function crawlProject(opts, onProgress) {
1481
1917
  logger.info({ url }, "[crawler] temporarily cleared session for public auth route");
1482
1918
  }
1483
1919
  }
1484
- const navErr15 = await page.goto(url, { waitUntil: "domcontentloaded", timeout: 15e3 }).then(() => null, (e) => e);
1920
+ const navErr15 = await page.goto(url, { waitUntil: "domcontentloaded", timeout: tuning.navTimeoutMs }).then(() => null, (e) => e);
1485
1921
  if (navErr15) {
1486
1922
  if (/timeout/i.test(String(navErr15?.message ?? navErr15))) {
1487
1923
  await page.goto(url, { waitUntil: "domcontentloaded", timeout: 3e4 }).catch((navErr) => {
@@ -1493,14 +1929,19 @@ async function crawlProject(opts, onProgress) {
1493
1929
  }
1494
1930
  rawPage.off("response", onRedirectResponse);
1495
1931
  if (redirectHops.length > 0) {
1932
+ const isCircularRedirect = new Set(redirectHops.map((h) => h.from)).size < redirectHops.length;
1496
1933
  redirectRegistry.register({
1497
1934
  originalUrl: url,
1498
1935
  finalUrl: page.url(),
1499
1936
  hops: redirectHops.map((h, i) => ({ ...h, durationMs: i === 0 ? Date.now() - navStart : 0 })),
1500
- isCircular: new Set(redirectHops.map((h) => h.from)).size < redirectHops.length,
1937
+ isCircular: isCircularRedirect,
1501
1938
  hopCount: redirectHops.length,
1502
1939
  totalDurationMs: Date.now() - navStart
1503
1940
  });
1941
+ if (isCircularRedirect) {
1942
+ logger.warn({ url, hops: redirectHops.map((h) => h.from) }, "[crawler] circular redirect detected \u2014 skipping URL");
1943
+ continue;
1944
+ }
1504
1945
  }
1505
1946
  const navMs = Date.now() - navStart;
1506
1947
  navCount++;
@@ -1510,9 +1951,36 @@ async function crawlProject(opts, onProgress) {
1510
1951
  console.warn(`[crawler] connection failed for ${url} \u2014 skipping (chrome-error page)`);
1511
1952
  continue;
1512
1953
  }
1513
- await page.waitForLoadState("load", { timeout: 8e3 }).catch(() => {
1954
+ await (rawPage ?? page).waitForLoadState("load", { timeout: 8e3 }).catch(() => {
1514
1955
  });
1515
- await waitForStableDOM(page);
1956
+ await waitForStableDOM(page, 8e3, 400, rawPage ?? void 0);
1957
+ const sparseCheck = await (rawPage ?? page).evaluate(
1958
+ () => document.querySelectorAll('button,a,input,select,textarea,[role="button"],[aria-label]').length
1959
+ ).catch(() => 99);
1960
+ if (sparseCheck < 15) {
1961
+ await (rawPage ?? page).waitForLoadState("networkidle", { timeout: 6e3 }).catch(() => {
1962
+ });
1963
+ await waitForStableDOM(page, 4e3, 300, rawPage ?? void 0);
1964
+ }
1965
+ try {
1966
+ const spinnerPage = rawPage ?? page;
1967
+ if (typeof spinnerPage.waitForFunction === "function") {
1968
+ await spinnerPage.waitForFunction(() => {
1969
+ const body = document.body;
1970
+ if (!body) return true;
1971
+ const text = body.innerText?.toLowerCase() ?? "";
1972
+ if (text.includes("checking authentication") || text.includes("checking auth") || text.includes("authenticating") || text.includes("verifying session") || text.includes("please wait") || text.includes("loading data") || text.includes("loading") && text.length < 200) return false;
1973
+ const busyEl = body.querySelector('[aria-busy="true"]');
1974
+ if (busyEl && busyEl.offsetParent !== null) return false;
1975
+ if (body.querySelector('[data-loading="true"],[data-pending="true"],[data-fetching="true"]')) return false;
1976
+ if (body.querySelector("progress:not([value])")) return false;
1977
+ const skeleton = body.querySelector(".skeleton,.skeleton-loader,.shimmer,.pulse-loader,.content-loader");
1978
+ if (skeleton && skeleton.offsetParent !== null) return false;
1979
+ return true;
1980
+ }, { timeout: 12e3 });
1981
+ }
1982
+ } catch {
1983
+ }
1516
1984
  if (!opts.discoveryMode) {
1517
1985
  try {
1518
1986
  const appModalVisible = await page.evaluate(() => {
@@ -1529,7 +1997,7 @@ async function crawlProject(opts, onProgress) {
1529
1997
  const modalShotPath = path.join(opts.screenshotDir ?? "/tmp", `modal-${modalHash}.png`);
1530
1998
  await page.screenshot({ path: modalShotPath, fullPage: false }).catch(() => {
1531
1999
  });
1532
- const modalName = await page.evaluate(() => {
2000
+ const modalName = await (rawPage ?? page).evaluate(() => {
1533
2001
  const h = document.querySelector('[role="dialog"] h1,[role="dialog"] h2,[role="dialog"] [role="heading"]');
1534
2002
  return h?.textContent?.trim() ?? "Modal";
1535
2003
  }).catch(() => "Modal");
@@ -1537,13 +2005,18 @@ async function crawlProject(opts, onProgress) {
1537
2005
  if (modalScreen) {
1538
2006
  await AppBrain.saveScreenshot(opts.tenantId, opts.projectId, modalScreen.id, modalShotPath, modalScreen.version, "DESKTOP").catch(() => {
1539
2007
  });
1540
- logger.info({ url, name: modalName }, "[crawler] captured app modal before dismiss");
2008
+ const modalElements = await domFallbackElements(page, rawPage ?? void 0).catch(() => []);
2009
+ for (const el of modalElements) {
2010
+ 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(() => {
2011
+ });
2012
+ }
2013
+ logger.info({ url, name: modalName, elementCount: modalElements.length }, "[crawler] captured app modal before dismiss");
1541
2014
  }
1542
2015
  }
1543
2016
  } catch {
1544
2017
  }
1545
2018
  await dismissOverlays(page);
1546
- await infiniteScrollToBottom(page, { maxScrolls: 30, scrollDelay: 350, stabilizeMs: 500 }).catch(() => {
2019
+ await infiniteScrollToBottom(page, { maxScrolls: 30, scrollDelay: 350, stabilizeMs: 500 }, rawPage ?? void 0).catch(() => {
1547
2020
  });
1548
2021
  }
1549
2022
  if (cdpSession?.off) cdpSession.off("Network.responseReceived", secHeadersCdp);
@@ -1563,6 +2036,22 @@ async function crawlProject(opts, onProgress) {
1563
2036
  const weWantedThisPage = BFS_ERROR_PATH.test(new URL(url).pathname) || BFS_LOGIN_PATH.test(new URL(url).pathname);
1564
2037
  const redirectedToError = !weWantedThisPage && (BFS_ERROR_PATH.test(new URL(landedUrl).pathname) || BFS_LOGIN_PATH.test(new URL(landedUrl).pathname));
1565
2038
  if (redirectedToError) {
2039
+ if (BFS_LOGIN_PATH.test(new URL(landedUrl).pathname)) {
2040
+ loginWallRedirectCount++;
2041
+ if (!opts.credentials && loginWallRedirectCount >= LOGIN_WALL_ABORT_THRESHOLD) {
2042
+ AppBrain.saveCrawlEvidence(opts.tenantId, opts.projectId, {
2043
+ jobId: opts.jobId,
2044
+ type: "AUTH_FAILURE",
2045
+ payload: {
2046
+ url,
2047
+ landed: landedUrl,
2048
+ reason: `Login wall detected: ${loginWallRedirectCount} consecutive URLs redirected to login. Add credentials in project settings to crawl authenticated pages.`
2049
+ }
2050
+ }).catch(() => {
2051
+ });
2052
+ throw new Error(`[AUTH_FAILURE] Login wall detected: ${loginWallRedirectCount} URLs redirected to the login page. Add credentials in project settings and retry the crawl.`);
2053
+ }
2054
+ }
1566
2055
  logger.warn({ requested: url, landed: landedUrl }, "[crawler] Mid-crawl redirect to auth/error \u2014 attempting re-auth");
1567
2056
  let reauthed = false;
1568
2057
  if (opts.credentials) {
@@ -1579,9 +2068,9 @@ async function crawlProject(opts, onProgress) {
1579
2068
  logger.info({ url }, "[crawler] Re-auth succeeded \u2014 retrying URL");
1580
2069
  await page.goto(url, { waitUntil: "domcontentloaded" }).catch(() => {
1581
2070
  });
1582
- await page.waitForLoadState("networkidle", { timeout: 8e3 }).catch(() => {
2071
+ await (rawPage ?? page).waitForLoadState("networkidle", { timeout: 8e3 }).catch(() => {
1583
2072
  });
1584
- await waitForStableDOM(page);
2073
+ await waitForStableDOM(page, 8e3, 400, rawPage ?? void 0);
1585
2074
  const afterReauthUrl = page.url();
1586
2075
  if (BFS_ERROR_PATH.test(new URL(afterReauthUrl).pathname) || BFS_LOGIN_PATH.test(new URL(afterReauthUrl).pathname)) {
1587
2076
  logger.warn({ url }, "[crawler] Still redirecting after re-auth \u2014 skipping this URL");
@@ -1598,6 +2087,10 @@ async function crawlProject(opts, onProgress) {
1598
2087
  throw new Error("[AUTH_FAILURE] Re-authentication failed. Session is unrecoverable. Re-capture the session in project settings and retry the crawl.");
1599
2088
  }
1600
2089
  } else {
2090
+ if (opts.discoveryMode) {
2091
+ logger.info({ url, landed: landedUrl }, "[discover] skipping auth-required URL \u2014 no credentials, continuing BFS");
2092
+ continue;
2093
+ }
1601
2094
  logger.warn("[crawler] Session expired mid-crawl, no credentials to recover \u2014 skipping URL");
1602
2095
  AppBrain.saveCrawlEvidence(opts.tenantId, opts.projectId, {
1603
2096
  jobId: opts.jobId,
@@ -1611,6 +2104,7 @@ async function crawlProject(opts, onProgress) {
1611
2104
  if (!redirectedToError && landedUrl !== url) {
1612
2105
  logger.info({ requested: url, landed: landedUrl }, "[crawler] navigated with redirect");
1613
2106
  }
2107
+ if (!redirectedToError) loginWallRedirectCount = 0;
1614
2108
  if (opts.discoveryMode) {
1615
2109
  visited.set(canonicalize(url), url);
1616
2110
  await (rawPage ?? page).waitForLoadState("networkidle", { timeout: 1e4 }).catch(() => {
@@ -1749,7 +2243,7 @@ async function crawlProject(opts, onProgress) {
1749
2243
  continue;
1750
2244
  }
1751
2245
  const preDomHash = await computeDomHash(page);
1752
- if (seenDomHashes.has(preDomHash)) {
2246
+ if (preDomHash !== "" && seenDomHashes.has(preDomHash)) {
1753
2247
  console.log(`[crawler] skip ${url} \u2014 duplicate DOM structure (same content as prior page)`);
1754
2248
  continue;
1755
2249
  }
@@ -1862,7 +2356,7 @@ async function crawlProject(opts, onProgress) {
1862
2356
  } catch {
1863
2357
  }
1864
2358
  try {
1865
- const activeNav = await page.locator('[aria-current="page"]').first().textContent({ timeout: 2e3 }).catch(() => null);
2359
+ const activeNav = await page.locator('[aria-current="page"]').first().textContent({ timeout: tuning.defaultTimeoutMs }).catch(() => null);
1866
2360
  if (activeNav) {
1867
2361
  const t = activeNav.trim();
1868
2362
  if (t.length > 1 && t.length < 60 && t.toLowerCase() !== brandName) return t;
@@ -1881,7 +2375,7 @@ async function crawlProject(opts, onProgress) {
1881
2375
  ".page-header h2"
1882
2376
  ];
1883
2377
  for (const sel of selectors) {
1884
- const h = await page.locator(sel).first().textContent({ timeout: 2e3 }).catch(() => null);
2378
+ const h = await page.locator(sel).first().textContent({ timeout: tuning.defaultTimeoutMs }).catch(() => null);
1885
2379
  if (!h) continue;
1886
2380
  const t = h.trim();
1887
2381
  if (t.length < 2 || t.length > 80) continue;
@@ -1971,21 +2465,24 @@ async function crawlProject(opts, onProgress) {
1971
2465
  if (cachedLinks) {
1972
2466
  logger.info({ url, cachedCount: cachedLinks.length }, "[crawler] link-discovery: using cached recipe \u2014 AI will still run and merge");
1973
2467
  }
1974
- const elapsedDiscoverySec = (Date.now() - crawlLoopStart) / 1e3;
1975
- const discoveryPct = Math.round(5 + Math.min(15, elapsedDiscoverySec / 60 * 15));
1976
- const preCapturePct = Math.max(lastEmittedPct, discoveryPct);
1977
- lastEmittedPct = preCapturePct;
1978
- await onProgress?.(preCapturePct, `Crawling ${visited.size} of ~${visited.size + queue.length} \xB7 ${(() => {
1979
- try {
1980
- return new URL(url).pathname || "/";
1981
- } catch {
1982
- return "/";
1983
- }
1984
- })()}`);
2468
+ {
2469
+ const effMax = isFinite(maxScreens) ? Math.min(visited.size + queue.length, maxScreens * 3) : visited.size + queue.length;
2470
+ maxSeenTotal = Math.max(maxSeenTotal, effMax);
2471
+ const rawPrePct = 20 + Math.round(Math.min(57, visited.size / Math.max(1, maxSeenTotal) * 57));
2472
+ const preCapturePct = Math.max(lastEmittedPct, rawPrePct);
2473
+ lastEmittedPct = preCapturePct;
2474
+ await onProgress?.(preCapturePct, `Crawling ${visited.size} of ~${visited.size + queue.length} \xB7 ${(() => {
2475
+ try {
2476
+ return new URL(url).pathname || "/";
2477
+ } catch {
2478
+ return "/";
2479
+ }
2480
+ })()}`);
2481
+ }
2482
+ await waitForScreenshotReady(page, rawPage ?? void 0);
1985
2483
  const preDomCount = await page.evaluate(
1986
2484
  () => document.querySelectorAll('button,a,input,select,textarea,[role="button"],[onclick],[aria-label]').length
1987
2485
  ).catch(() => 0);
1988
- await waitForScreenshotReady(page);
1989
2486
  await onProgress?.(lastEmittedPct, `Capturing ${(() => {
1990
2487
  try {
1991
2488
  return new URL(url).pathname || "/";
@@ -1993,8 +2490,8 @@ async function crawlProject(opts, onProgress) {
1993
2490
  return "/";
1994
2491
  }
1995
2492
  })()}`);
1996
- const pageDpr = await page.evaluate(() => window.devicePixelRatio ?? 1).catch(() => 1);
1997
- const elements = await extractElements(stagehand, page, elementLLM ?? void 0).catch((err) => {
2493
+ const pageDpr = await (rawPage ?? page).evaluate(() => window.devicePixelRatio ?? 1).catch(() => 1);
2494
+ const elements = await extractElements(stagehand, page, elementLLM ?? void 0, rawPage ?? void 0).catch((err) => {
1998
2495
  console.error("[crawler] extractElements failed:", err);
1999
2496
  return [];
2000
2497
  });
@@ -2045,8 +2542,8 @@ async function crawlProject(opts, onProgress) {
2045
2542
  }).catch((err) => console.error("[crawler] page.screenshot failed:", err)),
2046
2543
  // Always run AI link discovery (even when cache exists) — cache is merged in, not used as a replacement.
2047
2544
  // Skipping AI when cache exists was a bug: stale cache from broken crawls permanently blocked discovery.
2048
- // Skip AI on simple pages (< 5 interactive elements) — DOM-only is sufficient and saves ~12s per page.
2049
- discoverLinks(stagehand, origin, page, linkLLM ?? void 0, aiSkipCount > 0 || preDomCount < 5).then((freshLinks) => {
2545
+ // Note: preDomCount < aiMinElements guard removed — it was backwards: sparse pages (SPAs) need AI most.
2546
+ discoverLinks(stagehand, origin, page, linkLLM ?? void 0, aiSkipCount > 0, rawPage ?? void 0).then((freshLinks) => {
2050
2547
  if (aiSkipCount > 0) aiSkipCount--;
2051
2548
  aiErrorStreak = 0;
2052
2549
  const merged = (cachedLinks ? Array.from(/* @__PURE__ */ new Set([...cachedLinks, ...freshLinks])) : freshLinks).filter((href) => isUsefulNavigationUrl(href, origin));
@@ -2079,7 +2576,7 @@ async function crawlProject(opts, onProgress) {
2079
2576
  }
2080
2577
  return fallback;
2081
2578
  }),
2082
- readScreen(page).catch(() => [])
2579
+ readScreen(rawPage ?? page).catch(() => [])
2083
2580
  ]);
2084
2581
  await page.evaluate(() => {
2085
2582
  const container = document.getElementById("__zeta_element_highlights");
@@ -2100,6 +2597,104 @@ async function crawlProject(opts, onProgress) {
2100
2597
  }
2101
2598
  }).catch(() => {
2102
2599
  });
2600
+ {
2601
+ const safeContext = typeof rawPage?.context === "function" ? rawPage.context() : typeof page?.context === "function" ? page.context() : playwrightBrowser?.contexts()[0] ?? null;
2602
+ const roTimeout = new Promise(
2603
+ (resolve) => setTimeout(() => resolve([
2604
+ { status: "rejected", reason: "probe-timeout" },
2605
+ { status: "rejected", reason: "probe-timeout" },
2606
+ { status: "rejected", reason: "probe-timeout" },
2607
+ { status: "rejected", reason: "probe-timeout" },
2608
+ { status: "rejected", reason: "probe-timeout" }
2609
+ ]), 25e3)
2610
+ );
2611
+ const [nestedCtxRes, shadowRes, a11yRes, perfRes, swRes] = await Promise.race([
2612
+ Promise.allSettled([
2613
+ crawlNestedContexts(rawPage ?? page, safeContext, url),
2614
+ crawlShadowDom(rawPage ?? page),
2615
+ crawlAccessibilityTree(rawPage ?? page),
2616
+ collectPerfMetrics(rawPage ?? page, url),
2617
+ inspectServiceWorker(rawPage ?? page)
2618
+ ]),
2619
+ roTimeout
2620
+ ]);
2621
+ const roPayload = { screenId: screen.id, url };
2622
+ if (nestedCtxRes.status === "fulfilled" && nestedCtxRes.value && nestedCtxRes.value.totalContextsFound > 0)
2623
+ roPayload.nestedContexts = nestedCtxRes.value;
2624
+ if (shadowRes.status === "fulfilled" && shadowRes.value && shadowRes.value.shadowHostCount > 0)
2625
+ roPayload.shadowDom = shadowRes.value;
2626
+ if (a11yRes.status === "fulfilled" && a11yRes.value)
2627
+ roPayload.accessibilityTree = a11yRes.value;
2628
+ if (perfRes.status === "fulfilled" && perfRes.value.requestCount > 0)
2629
+ roPayload.performance = perfRes.value;
2630
+ if (swRes.status === "fulfilled" && swRes.value.supported)
2631
+ roPayload.serviceWorker = swRes.value;
2632
+ if (Object.keys(roPayload).length > 2) {
2633
+ AppBrain.saveCrawlEvidence(opts.tenantId, opts.projectId, {
2634
+ jobId: opts.jobId,
2635
+ type: "PER_SCREEN_PROBES",
2636
+ payload: roPayload
2637
+ }).catch(() => {
2638
+ });
2639
+ }
2640
+ if (screen?.id && opts.interactive !== false && safeContext) {
2641
+ void (async () => {
2642
+ let probePage = null;
2643
+ try {
2644
+ const openProbePages = safeContext.pages().length;
2645
+ if (openProbePages > 8) {
2646
+ logger.warn({ url, openProbePages }, "[crawler] probe page cap reached \u2014 skipping mutating probes for this screen");
2647
+ return;
2648
+ }
2649
+ probePage = await safeContext.newPage();
2650
+ await probePage.goto(url, { waitUntil: "domcontentloaded", timeout: 15e3 }).catch(() => {
2651
+ });
2652
+ await probePage.waitForLoadState("networkidle", { timeout: 8e3 }).catch(() => {
2653
+ });
2654
+ await waitForStableDOM(probePage, 4e3, 400).catch(() => {
2655
+ });
2656
+ const muTimeout = new Promise(
2657
+ (resolve) => setTimeout(() => resolve([
2658
+ { status: "rejected", reason: "probe-timeout" },
2659
+ { status: "rejected", reason: "probe-timeout" },
2660
+ { status: "rejected", reason: "probe-timeout" },
2661
+ { status: "rejected", reason: "probe-timeout" }
2662
+ ]), 6e4)
2663
+ );
2664
+ const [dropEnumRes, crudRes, filterRes, advancedRes] = await Promise.race([
2665
+ Promise.allSettled([
2666
+ enumerateDropdowns(probePage),
2667
+ probeCrudFlows(probePage, name),
2668
+ crawlFiltersAndPagination(probePage),
2669
+ probeAdvancedUI(probePage)
2670
+ ]),
2671
+ muTimeout
2672
+ ]);
2673
+ const muPayload = { screenId: screen.id, url };
2674
+ if (dropEnumRes.status === "fulfilled" && dropEnumRes.value.totalOptionsFound > 0)
2675
+ muPayload.dropdowns = dropEnumRes.value;
2676
+ if (crudRes.status === "fulfilled" && (crudRes.value.flows?.length > 0 || crudRes.value.toastsObserved?.length > 0))
2677
+ muPayload.crudFlows = crudRes.value;
2678
+ if (filterRes.status === "fulfilled" && filterRes.value.filters?.length > 0)
2679
+ muPayload.filtersAndPagination = filterRes.value;
2680
+ if (advancedRes.status === "fulfilled" && advancedRes.value && (advancedRes.value.datePickers?.length > 0 || advancedRes.value.sliders?.length > 0 || advancedRes.value.typeaheads?.length > 0 || advancedRes.value.dragDrop?.library !== "none"))
2681
+ muPayload.advancedUI = advancedRes.value;
2682
+ if (Object.keys(muPayload).length > 2) {
2683
+ AppBrain.saveCrawlEvidence(opts.tenantId, opts.projectId, {
2684
+ jobId: opts.jobId,
2685
+ type: "PER_SCREEN_PROBES_MUTATING",
2686
+ payload: muPayload
2687
+ }).catch(() => {
2688
+ });
2689
+ }
2690
+ } catch {
2691
+ } finally {
2692
+ await probePage?.close().catch(() => {
2693
+ });
2694
+ }
2695
+ })();
2696
+ }
2697
+ }
2103
2698
  try {
2104
2699
  const domContent = await capturePageHtml(page);
2105
2700
  if (!domContent) {
@@ -2341,159 +2936,196 @@ ${snippet}`,
2341
2936
  })();
2342
2937
  }
2343
2938
  const linkMappings = await extractLinkMappings(page, origin);
2344
- await AppBrain.clearScreenElements(opts.tenantId, opts.projectId, screen.id).catch(() => {
2345
- });
2346
- for (let _ei = 0; _ei < elements.length; _ei += 4) {
2347
- const _batch = elements.slice(_ei, _ei + 4);
2348
- await Promise.all(_batch.map(async (el) => {
2349
- 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);
2350
- const label = el.meaning;
2351
- const role = el.role ?? "unknown";
2352
- const selectorHint = atByRole.get(role) ?? role;
2353
- const selectors = [
2354
- { type: "role-label", value: `${role}:${label}`, confidence: 0.9 },
2355
- { type: "aria-label", value: label, confidence: 0.85 },
2356
- { type: "tag-hint", value: selectorHint, confidence: 0.6 }
2357
- ];
2358
- AppBrain.saveElementFingerprint(opts.tenantId, opts.projectId, saved.id, selectors).catch(console.warn);
2359
- }));
2360
- }
2361
- for (const lm of linkMappings) {
2362
- const safety = assessActionSafety(lm.label, domainProfile);
2363
- if (!safety.allowed) {
2364
- AppBrain.saveCrawlEvidence(opts.tenantId, opts.projectId, {
2365
- screenId: screen.id,
2366
- jobId: opts.jobId,
2367
- type: "ACTION_BLOCKED_BY_SAFETY_POLICY",
2368
- payload: { label: lm.label, href: lm.href, decision: safety, profile: domainProfile.industry }
2369
- }).catch(() => {
2370
- });
2371
- continue;
2939
+ const postExtractUrl = (() => {
2940
+ try {
2941
+ return page.url();
2942
+ } catch {
2943
+ return url;
2372
2944
  }
2373
- pendingTransitions.push({ fromScreenId: screen.id, elementMeaning: lm.label, targetUrl: lm.href });
2374
- AppBrain.saveActionRecipe(opts.tenantId, opts.projectId, {
2375
- domain: new URL(url).hostname,
2376
- purpose: `navigate:${lm.label}`,
2377
- fingerprint: sha256(`${canonicalize(url)}:${lm.label}:${canonicalize(lm.href)}`),
2378
- steps: [{ action: "click", label: lm.label, targetUrl: lm.href, fromUrl: url }],
2379
- successRate: 0.5
2945
+ })();
2946
+ const postExtractParsed = (() => {
2947
+ try {
2948
+ return new URL(postExtractUrl);
2949
+ } catch {
2950
+ return new URL(url);
2951
+ }
2952
+ })();
2953
+ const driftedToAuthWall = !weWantedThisPage && (BFS_LOGIN_PATH.test(postExtractParsed.pathname) || BFS_ERROR_PATH.test(postExtractParsed.pathname));
2954
+ if (driftedToAuthWall) {
2955
+ logger.warn({ url, postExtractUrl }, "[crawler] Page drifted to auth wall during element extraction \u2014 skipping element save to prevent login-page element contamination");
2956
+ AppBrain.saveCrawlEvidence(opts.tenantId, opts.projectId, {
2957
+ screenId: screen.id,
2958
+ jobId: opts.jobId,
2959
+ type: "AUTH_FAILURE",
2960
+ payload: { url, landed: postExtractUrl, reason: "Session expired during element extraction. Re-capture session and re-crawl this page." }
2380
2961
  }).catch(() => {
2381
2962
  });
2382
- if (!selective) {
2383
- try {
2384
- const targetPath = new URL(lm.href).pathname;
2385
- const canonLmHref = canonicalize(lm.href);
2386
- if (!SKIP_PATH.test(targetPath) && isUsefulNavigationUrl(lm.href, origin) && !queued.has(canonLmHref) && !visited.has(canonLmHref) && queued.size < maxScreens * 2) {
2387
- if (robotsCache && !await robotsCache.isAllowed(lm.href)) continue;
2388
- queued.add(canonLmHref);
2389
- queue.push(lm.href, { parentElements: elements.length });
2390
- edges.push({ fromUrl: url, toUrl: lm.href });
2391
- logger.info({ fromUrl: url, newUrl: lm.href, label: lm.label, queueSize: queue.length }, "[crawler] anchor mapping added to queue");
2392
- }
2393
- } catch {
2963
+ } else {
2964
+ if (elements.length === 0) {
2965
+ logger.warn({ url, screenId: screen.id }, "[crawler] extractElements returned 0 \u2014 preserving existing elements to avoid data loss");
2966
+ if (!revisitQueue.includes(url)) {
2967
+ revisitQueue.push(url);
2968
+ logger.info({ url }, "[crawler] 0-element page queued for re-visit with extended hydration wait");
2969
+ }
2970
+ } else {
2971
+ await AppBrain.clearScreenElements(opts.tenantId, opts.projectId, screen.id).catch(() => {
2972
+ });
2973
+ for (let _ei = 0; _ei < elements.length; _ei += 4) {
2974
+ const _batch = elements.slice(_ei, _ei + 4);
2975
+ await Promise.all(_batch.map(async (el) => {
2976
+ 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);
2977
+ const label = el.meaning;
2978
+ const role = el.role ?? "unknown";
2979
+ const selectorHint = atByRole.get(role) ?? role;
2980
+ const selectors = [
2981
+ { type: "role-label", value: `${role}:${label}`, confidence: 0.9 },
2982
+ { type: "aria-label", value: label, confidence: 0.85 },
2983
+ { type: "tag-hint", value: selectorHint, confidence: 0.6 }
2984
+ ];
2985
+ AppBrain.saveElementFingerprint(opts.tenantId, opts.projectId, saved.id, selectors).catch(console.warn);
2986
+ }));
2394
2987
  }
2395
2988
  }
2396
- }
2397
- if (!selective) {
2398
- for (const href of nextLinks) {
2399
- edges.push({ fromUrl: url, toUrl: href });
2400
- try {
2401
- const hrefParams = new URL(href).searchParams;
2402
- const actionVal = hrefParams.get("action") ?? "";
2403
- if (/^yith-|^wc-|^vc_|^elementor/.test(actionVal)) continue;
2404
- } catch {
2989
+ for (const lm of linkMappings) {
2990
+ const safety = assessActionSafety(lm.label, domainProfile);
2991
+ if (!safety.allowed) {
2992
+ AppBrain.saveCrawlEvidence(opts.tenantId, opts.projectId, {
2993
+ screenId: screen.id,
2994
+ jobId: opts.jobId,
2995
+ type: "ACTION_BLOCKED_BY_SAFETY_POLICY",
2996
+ payload: { label: lm.label, href: lm.href, decision: safety, profile: domainProfile.industry }
2997
+ }).catch(() => {
2998
+ });
2999
+ continue;
2405
3000
  }
2406
- const canonHref = canonicalize(href);
2407
- if (!queued.has(canonHref) && !visited.has(canonHref) && queued.size < maxScreens * 2) {
3001
+ pendingTransitions.push({ fromScreenId: screen.id, elementMeaning: lm.label, targetUrl: lm.href });
3002
+ AppBrain.saveActionRecipe(opts.tenantId, opts.projectId, {
3003
+ domain: new URL(url).hostname,
3004
+ purpose: `navigate:${lm.label}`,
3005
+ fingerprint: sha256(`${canonicalize(url)}:${lm.label}:${canonicalize(lm.href)}`),
3006
+ steps: [{ action: "click", label: lm.label, targetUrl: lm.href, fromUrl: url }],
3007
+ successRate: 0.5
3008
+ }).catch(() => {
3009
+ });
3010
+ if (!selective) {
2408
3011
  try {
2409
- const hrefPath = new URL(href).pathname;
2410
- if (isGeneratedDynamicPath(hrefPath)) continue;
3012
+ const targetPath = new URL(lm.href).pathname;
3013
+ const canonLmHref = canonicalize(lm.href);
3014
+ if (!SKIP_PATH.test(targetPath) && isUsefulNavigationUrl(lm.href, origin) && !queued.has(canonLmHref) && !visited.has(canonLmHref) && queued.size < maxScreens * 2) {
3015
+ if (robotsCache && !await robotsCache.isAllowed(lm.href)) continue;
3016
+ queued.add(canonLmHref);
3017
+ queue.push(lm.href, { parentElements: elements.length });
3018
+ edges.push({ fromUrl: url, toUrl: lm.href });
3019
+ logger.info({ fromUrl: url, newUrl: lm.href, label: lm.label, queueSize: queue.length }, "[crawler] anchor mapping added to queue");
3020
+ }
2411
3021
  } catch {
2412
3022
  }
2413
- if (robotsCache && !await robotsCache.isAllowed(href)) continue;
2414
- queued.add(canonHref);
2415
- queue.push(href, { parentElements: elements.length });
2416
- logger.info({ newUrl: href, queueSize: queue.length }, "[crawler] new URL added to queue");
2417
3023
  }
2418
3024
  }
2419
- logger.info({ fromUrl: url, discovered: nextLinks.length, queueAfter: queue.length, visitedSoFar: visited.size }, "[crawler] link processing done");
2420
- try {
2421
- const jsRoutes = await page.evaluate(() => {
2422
- const routes = window.__zetaDiscoveredRoutes ?? [];
2423
- window.__zetaDiscoveredRoutes = [];
2424
- return routes;
2425
- }).catch(() => []);
2426
- for (const route of jsRoutes) {
3025
+ if (!selective) {
3026
+ for (const href of nextLinks) {
3027
+ edges.push({ fromUrl: url, toUrl: href });
2427
3028
  try {
2428
- const abs = route.startsWith("http") ? route : `${origin}${route.startsWith("/") ? "" : "/"}${route}`;
2429
- const canonRoute = canonicalize(abs);
2430
- if (!queued.has(canonRoute) && !visited.has(canonRoute)) {
2431
- queued.add(canonRoute);
2432
- queue.push(abs);
2433
- logger.info({ route: abs }, "[crawler] history.pushState: discovered SPA route");
2434
- }
3029
+ const hrefParams = new URL(href).searchParams;
3030
+ const actionVal = hrefParams.get("action") ?? "";
3031
+ if (/^yith-|^wc-|^vc_|^elementor/.test(actionVal)) continue;
2435
3032
  } catch {
2436
3033
  }
3034
+ const canonHref = canonicalize(href);
3035
+ if (!queued.has(canonHref) && !visited.has(canonHref) && queued.size < maxScreens * 2) {
3036
+ try {
3037
+ const hrefPath = new URL(href).pathname;
3038
+ if (isGeneratedDynamicPath(hrefPath)) continue;
3039
+ } catch {
3040
+ }
3041
+ if (robotsCache && !await robotsCache.isAllowed(href)) continue;
3042
+ queued.add(canonHref);
3043
+ queue.push(href, { parentElements: elements.length });
3044
+ logger.info({ newUrl: href, queueSize: queue.length }, "[crawler] new URL added to queue");
3045
+ }
2437
3046
  }
2438
- } catch {
2439
- }
2440
- if (preDomCount < 25 && !revisitQueue.includes(url)) {
3047
+ logger.info({ fromUrl: url, discovered: nextLinks.length, queueAfter: queue.length, visitedSoFar: visited.size }, "[crawler] link processing done");
2441
3048
  try {
2442
- const postDomCount = await page.evaluate(
2443
- () => document.querySelectorAll('button,a,input,select,textarea,[role="button"],[onclick],[aria-label]').length
2444
- ).catch(() => 0);
2445
- if (postDomCount > preDomCount * 1.3 + 4) {
2446
- revisitQueue.push(url);
2447
- logger.info({ url, preDomCount, postDomCount }, "[P3a] Async content detected \u2014 scheduled for re-visit");
3049
+ const jsRoutes = await page.evaluate(() => {
3050
+ const routes = window.__zetaDiscoveredRoutes ?? [];
3051
+ window.__zetaDiscoveredRoutes = [];
3052
+ return routes;
3053
+ }).catch(() => []);
3054
+ for (const route of jsRoutes) {
3055
+ try {
3056
+ const abs = route.startsWith("http") ? route : `${origin}${route.startsWith("/") ? "" : "/"}${route}`;
3057
+ const canonRoute = canonicalize(abs);
3058
+ if (!queued.has(canonRoute) && !visited.has(canonRoute)) {
3059
+ queued.add(canonRoute);
3060
+ queue.push(abs);
3061
+ logger.info({ route: abs }, "[crawler] history.pushState: discovered SPA route");
3062
+ }
3063
+ } catch {
3064
+ }
2448
3065
  }
2449
3066
  } catch {
2450
3067
  }
2451
- }
2452
- if (visited.size <= 5) {
2453
- try {
2454
- const jsDetectedRoutes = await detectJsRoutes(page, origin);
2455
- for (const route of jsDetectedRoutes) {
2456
- const canonRoute = canonicalize(route);
2457
- if (isUsefulNavigationUrl(route, origin) && !queued.has(canonRoute) && !visited.has(canonRoute) && queued.size < maxScreens * 2) {
2458
- queued.add(canonRoute);
2459
- queue.push(route, { parentElements: elements.length });
2460
- logger.info({ route }, "[P2] JS router: discovered route added to queue");
3068
+ const asyncContentCheckMinDom = parseInt(process.env.CRAWLER_ASYNC_CONTENT_MIN_DOM ?? "") || 25;
3069
+ if (preDomCount < asyncContentCheckMinDom && !revisitQueue.includes(url)) {
3070
+ try {
3071
+ const postDomCount = await page.evaluate(
3072
+ () => document.querySelectorAll('button,a,input,select,textarea,[role="button"],[onclick],[aria-label]').length
3073
+ ).catch(() => 0);
3074
+ const asyncGrowthRatio = parseFloat(process.env.CRAWLER_ASYNC_GROWTH_RATIO ?? "") || 1.3;
3075
+ const asyncGrowthFloor = parseInt(process.env.CRAWLER_ASYNC_GROWTH_FLOOR ?? "") || 4;
3076
+ if (postDomCount > preDomCount * asyncGrowthRatio + asyncGrowthFloor) {
3077
+ revisitQueue.push(url);
3078
+ logger.info({ url, preDomCount, postDomCount }, "[P3a] Async content detected \u2014 scheduled for re-visit");
2461
3079
  }
3080
+ } catch {
2462
3081
  }
2463
- } catch {
2464
3082
  }
2465
- }
2466
- for (const relPath of [...xhrDiscoveredPaths]) {
2467
- xhrDiscoveredPaths.delete(relPath);
2468
- try {
2469
- const abs = `${origin}${relPath}`;
2470
- const canon = canonicalize(abs);
2471
- if (isUsefulNavigationUrl(abs, origin) && !queued.has(canon) && !visited.has(canon) && queued.size < maxScreens * 2) {
2472
- queued.add(canon);
2473
- queue.push(abs);
2474
- logger.info({ url: abs }, "[P4] XHR: page URL from JSON response added to queue");
3083
+ if (visited.size <= (parseInt(process.env.CRAWLER_SPA_ROUTER_DETECT_LIMIT ?? "") || 5)) {
3084
+ try {
3085
+ const jsDetectedRoutes = await detectJsRoutes(page, origin);
3086
+ for (const route of jsDetectedRoutes) {
3087
+ const canonRoute = canonicalize(route);
3088
+ if (isUsefulNavigationUrl(route, origin) && !queued.has(canonRoute) && !visited.has(canonRoute) && queued.size < maxScreens * 2) {
3089
+ queued.add(canonRoute);
3090
+ queue.push(route, { parentElements: elements.length });
3091
+ logger.info({ route }, "[P2] JS router: discovered route added to queue");
3092
+ }
3093
+ }
3094
+ } catch {
2475
3095
  }
2476
- } catch {
2477
3096
  }
2478
- }
2479
- if (nextLinks.length === 0 && elements.length < 5 && opts.skyvernConfig) {
2480
- try {
2481
- logger.info({ url }, "Gap 5: thin page detected \u2014 asking Skyvern to discover navigation URLs");
2482
- const skyvernNav = await skyvernNavigate(
2483
- url,
2484
- ["Find and return all navigation links, menu items, and page URLs accessible from this screen"],
2485
- opts.skyvernConfig
2486
- );
2487
- for (const skyvernUrl of skyvernNav.visitedUrls) {
2488
- const canonSkyvernUrl = canonicalize(skyvernUrl);
2489
- if (!queued.has(canonSkyvernUrl) && !visited.has(canonSkyvernUrl) && queued.size < maxScreens * 2) {
2490
- logger.info({ url: skyvernUrl }, "Gap 5: Skyvern-discovered URL added to ZeTa crawl queue");
2491
- queued.add(canonSkyvernUrl);
2492
- queue.push(skyvernUrl);
2493
- pendingTransitions.push({ fromScreenId: screen.id, elementMeaning: "Skyvern-discovered link", targetUrl: skyvernUrl });
3097
+ for (const relPath of [...xhrDiscoveredPaths]) {
3098
+ xhrDiscoveredPaths.delete(relPath);
3099
+ try {
3100
+ const abs = `${origin}${relPath}`;
3101
+ const canon = canonicalize(abs);
3102
+ if (isUsefulNavigationUrl(abs, origin) && !queued.has(canon) && !visited.has(canon) && queued.size < maxScreens * 2) {
3103
+ queued.add(canon);
3104
+ queue.push(abs);
3105
+ logger.info({ url: abs }, "[P4] XHR: page URL from JSON response added to queue");
2494
3106
  }
3107
+ } catch {
3108
+ }
3109
+ }
3110
+ if (nextLinks.length === 0 && elements.length < (parseInt(process.env.CRAWL_SKYVERN_PAGE_MIN_ELEMENTS ?? "") || 5) && opts.skyvernConfig) {
3111
+ try {
3112
+ logger.info({ url }, "Gap 5: thin page detected \u2014 asking Skyvern to discover navigation URLs");
3113
+ const skyvernNav = await skyvernNavigate(
3114
+ url,
3115
+ ["Find and return all navigation links, menu items, and page URLs accessible from this screen"],
3116
+ opts.skyvernConfig
3117
+ );
3118
+ for (const skyvernUrl of skyvernNav.visitedUrls) {
3119
+ const canonSkyvernUrl = canonicalize(skyvernUrl);
3120
+ if (!queued.has(canonSkyvernUrl) && !visited.has(canonSkyvernUrl) && queued.size < maxScreens * 2) {
3121
+ logger.info({ url: skyvernUrl }, "Gap 5: Skyvern-discovered URL added to ZeTa crawl queue");
3122
+ queued.add(canonSkyvernUrl);
3123
+ queue.push(skyvernUrl);
3124
+ pendingTransitions.push({ fromScreenId: screen.id, elementMeaning: "Skyvern-discovered link", targetUrl: skyvernUrl });
3125
+ }
3126
+ }
3127
+ } catch {
2495
3128
  }
2496
- } catch {
2497
3129
  }
2498
3130
  }
2499
3131
  }
@@ -2569,7 +3201,8 @@ ${snippet}`,
2569
3201
  };
2570
3202
  }
2571
3203
  const successfulScreenCount = discovered.filter((d) => d.screenId).length;
2572
- if (successfulScreenCount < 10 && opts.skyvernConfig) {
3204
+ const skyvernMinScreens = parseInt(process.env.CRAWL_SKYVERN_MIN_SCREENS ?? "") || 10;
3205
+ if (successfulScreenCount < skyvernMinScreens && opts.skyvernConfig) {
2573
3206
  await onProgress?.(78, "Few screens found \u2014 using Skyvern to discover additional navigation URLs");
2574
3207
  const currentUrl = page.url();
2575
3208
  const skyvernNav = await skyvernNavigate(
@@ -2587,16 +3220,16 @@ ${snippet}`,
2587
3220
  if (visited.size >= maxScreens) break;
2588
3221
  try {
2589
3222
  await page.goto(skyvernUrl, { waitUntil: "domcontentloaded", timeout: 2e4 });
2590
- await page.waitForTimeout(1500);
3223
+ await new Promise((r) => setTimeout(r, 1500));
2591
3224
  const skyvernName = new URL(skyvernUrl).pathname.split("/").filter(Boolean).pop() ?? "Screen";
2592
3225
  const screen = await AppBrain.saveScreen(opts.tenantId, opts.projectId, skyvernName, skyvernUrl, opts.jobId, "SKYVERN_AI");
2593
3226
  const shotPath = `${opts.screenshotDir ?? "/tmp"}/${screen.id}-v${screen.version}.png`;
2594
- await waitForScreenshotReady(page);
3227
+ await waitForScreenshotReady(page, rawPage ?? void 0);
2595
3228
  await page.screenshot({ path: shotPath, fullPage: true });
2596
3229
  await AppBrain.saveScreenshot(opts.tenantId, opts.projectId, screen.id, shotPath, screen.version, "DESKTOP");
2597
3230
  const [elems, links, linkMappings] = await Promise.all([
2598
- extractElements(stagehand, page, elementLLM ?? void 0),
2599
- discoverLinks(stagehand, origin, page, linkLLM ?? void 0, aiSkipCount > 0),
3231
+ extractElements(stagehand, page, elementLLM ?? void 0, rawPage ?? void 0),
3232
+ discoverLinks(stagehand, origin, page, linkLLM ?? void 0, aiSkipCount > 0, rawPage ?? void 0),
2600
3233
  extractLinkMappings(page, origin)
2601
3234
  ]);
2602
3235
  for (const el of elems) {
@@ -2626,15 +3259,15 @@ ${snippet}`,
2626
3259
  if (!isUsefulNavigationUrl(href, origin)) continue;
2627
3260
  try {
2628
3261
  await page.goto(href, { waitUntil: "domcontentloaded", timeout: 2e4 });
2629
- await page.waitForTimeout(1e3);
3262
+ await new Promise((r) => setTimeout(r, 1e3));
2630
3263
  const nm = new URL(href).pathname.split("/").filter(Boolean).pop() ?? "Screen";
2631
3264
  const sc = await AppBrain.saveScreen(opts.tenantId, opts.projectId, nm, href, opts.jobId, "SKYVERN_AI");
2632
3265
  const sp = `${opts.screenshotDir ?? "/tmp"}/${sc.id}-v${sc.version}.png`;
2633
- await waitForScreenshotReady(page);
3266
+ await waitForScreenshotReady(page, rawPage ?? void 0);
2634
3267
  await page.screenshot({ path: sp, fullPage: true });
2635
3268
  await AppBrain.saveScreenshot(opts.tenantId, opts.projectId, sc.id, sp, sc.version, "DESKTOP");
2636
3269
  const [elems2, linkMappings2] = await Promise.all([
2637
- extractElements(stagehand, page, elementLLM ?? void 0),
3270
+ extractElements(stagehand, page, elementLLM ?? void 0, rawPage ?? void 0),
2638
3271
  extractLinkMappings(page, origin)
2639
3272
  ]);
2640
3273
  for (const el of elems2) {
@@ -2680,28 +3313,51 @@ ${snippet}`,
2680
3313
  );
2681
3314
  }
2682
3315
  } catch (err) {
2683
- logger.warn({ err: String(err) }, "[P1] URL pattern completion failed \u2014 non-fatal, continuing");
3316
+ const msg = String(err);
3317
+ logger.warn({ err: msg }, "[P1] URL pattern completion failed \u2014 non-fatal, continuing");
3318
+ crawlWarnings.push(`url-completer skipped: ${msg.includes("401") || msg.toLowerCase().includes("api key") || msg.toLowerCase().includes("authentication") ? "LLM API key invalid or missing" : msg}`);
2684
3319
  }
2685
3320
  if (revisitQueue.length > 0) {
2686
3321
  await onProgress?.(79, `Re-visiting ${revisitQueue.length} page(s) with async content\u2026`);
2687
3322
  logger.info({ count: revisitQueue.length }, "[P3a] Starting async content re-visit pass");
2688
3323
  for (const revisitUrl of revisitQueue.slice(0, 10)) {
2689
- const existingScreenId = visited.get(canonicalize(revisitUrl));
2690
- if (!existingScreenId) continue;
3324
+ const rawScreenId = visited.get(canonicalize(revisitUrl));
3325
+ const isAuthWallRevisit = rawScreenId === "_auth_wall_";
3326
+ if (!rawScreenId) continue;
2691
3327
  try {
2692
3328
  await page.goto(revisitUrl, { waitUntil: "networkidle", timeout: 25e3 });
2693
- await page.waitForTimeout(2e3);
3329
+ await new Promise((r) => setTimeout(r, 2e3));
3330
+ await waitForScreenshotReady(page, rawPage ?? void 0);
3331
+ const landedUrl = (rawPage ?? page).url?.() ?? revisitUrl;
3332
+ const landedCanon = canonicalize(landedUrl);
3333
+ let existingScreenId = isAuthWallRevisit ? visited.get(landedCanon) ?? null : rawScreenId;
3334
+ if (isAuthWallRevisit && (!existingScreenId || existingScreenId.startsWith("_")) && !visited.has(landedCanon)) {
3335
+ const pathParts = new URL(landedUrl).pathname.split("/").filter(Boolean);
3336
+ const screenName = pathParts.map((p) => p.charAt(0).toUpperCase() + p.slice(1).replace(/[-_]/g, " ")).join(" \u2014 ") || "Home";
3337
+ const newScreen = await AppBrain.saveScreen(opts.tenantId, opts.projectId, screenName, landedUrl, opts.jobId, "LINK_FOLLOW").catch(() => null);
3338
+ if (newScreen) {
3339
+ visited.set(landedCanon, newScreen.id);
3340
+ existingScreenId = newScreen.id;
3341
+ }
3342
+ }
3343
+ if (!existingScreenId || existingScreenId.startsWith("_")) {
3344
+ logger.warn({ url: revisitUrl, landed: landedUrl }, "[P3a] Re-visit skipped \u2014 could not resolve screen ID");
3345
+ continue;
3346
+ }
2694
3347
  const shotPath2 = `${opts.screenshotDir ?? "/tmp"}/${existingScreenId}-revisit.png`;
2695
- await waitForScreenshotReady(page);
2696
3348
  await page.screenshot({ path: shotPath2, fullPage: true });
2697
- await AppBrain.saveScreenshot(opts.tenantId, opts.projectId, existingScreenId, shotPath2, 1, "DESKTOP").catch(() => {
3349
+ await AppBrain.saveScreenshot(opts.tenantId, opts.projectId, existingScreenId, shotPath2, 1, "DESKTOP", void 0).catch(() => {
2698
3350
  });
2699
- const revisitElements = await extractElements(stagehand, page, elementLLM ?? void 0).catch(() => []);
2700
- for (const el of revisitElements) {
2701
- await AppBrain.saveElement(opts.tenantId, opts.projectId, existingScreenId, el.meaning, el.role, el.expectedData, el.notes, void 0, true, el.boundingRect, el.ariaState, el.parentLandmark).catch(() => {
3351
+ const revisitElements = await extractElements(stagehand, page, elementLLM ?? void 0, rawPage ?? void 0).catch(() => []);
3352
+ if (revisitElements.length > 0) {
3353
+ await AppBrain.clearScreenElements(opts.tenantId, opts.projectId, existingScreenId).catch(() => {
2702
3354
  });
3355
+ for (const el of revisitElements) {
3356
+ await AppBrain.saveElement(opts.tenantId, opts.projectId, existingScreenId, el.meaning, el.role, el.expectedData, el.notes, void 0, true, el.boundingRect, el.ariaState, el.parentLandmark).catch(() => {
3357
+ });
3358
+ }
2703
3359
  }
2704
- const revisitLinks = await discoverLinks(stagehand, origin, page, linkLLM ?? void 0, false).catch(() => []);
3360
+ const revisitLinks = await discoverLinks(stagehand, origin, page, linkLLM ?? void 0, false, rawPage ?? void 0).catch(() => []);
2705
3361
  for (const href of revisitLinks) {
2706
3362
  const canon = canonicalize(href);
2707
3363
  if (isUsefulNavigationUrl(href, origin) && !queued.has(canon) && !visited.has(canon) && queued.size < maxScreens * 2) {
@@ -2709,13 +3365,68 @@ ${snippet}`,
2709
3365
  queue.push(href);
2710
3366
  }
2711
3367
  }
2712
- logger.info({ url: revisitUrl, elements: revisitElements.length, newLinks: revisitLinks.length }, "[P3a] Re-visit complete");
3368
+ logger.info({ url: revisitUrl, landed: landedUrl, elements: revisitElements.length, newLinks: revisitLinks.length }, "[P3a] Re-visit complete");
2713
3369
  } catch (err) {
2714
3370
  logger.warn({ url: revisitUrl, err: String(err) }, "[P3a] Re-visit failed \u2014 non-fatal");
2715
3371
  }
2716
3372
  }
2717
3373
  }
2718
- if (visited.size >= 3) {
3374
+ if (queue.length > 0 && visited.size < maxScreens) {
3375
+ const p3bCount = Math.min(queue.length, maxScreens - visited.size);
3376
+ logger.info({ newLinks: p3bCount }, "[P3b] Extended BFS \u2014 processing links discovered during revisit pass");
3377
+ await onProgress?.(82, `Extended crawl: ${p3bCount} newly discovered page(s)\u2026`);
3378
+ while (queue.length > 0 && visited.size < maxScreens && visited.size < segmentSize) {
3379
+ const href = queue.shift();
3380
+ if (!href) break;
3381
+ const canonHref = canonicalize(href);
3382
+ if (visited.has(canonHref)) continue;
3383
+ if (!isUsefulNavigationUrl(href, origin)) continue;
3384
+ visited.set(canonHref, "_pending_");
3385
+ try {
3386
+ await page.goto(href, { waitUntil: "networkidle", timeout: 25e3 }).catch(() => {
3387
+ });
3388
+ await waitForScreenshotReady(page, rawPage ?? void 0);
3389
+ const pathParts = new URL(href).pathname.split("/").filter(Boolean);
3390
+ const nm = pathParts.map((p) => p.charAt(0).toUpperCase() + p.slice(1).replace(/[-_]/g, " ")).join(" \u2014 ") || "Home";
3391
+ const screen = await AppBrain.saveScreen(opts.tenantId, opts.projectId, nm, href, opts.jobId, "LINK_FOLLOW");
3392
+ visited.set(canonHref, screen.id);
3393
+ const sp = `${opts.screenshotDir ?? "/tmp"}/${screen.id}-v${screen.version}.png`;
3394
+ await page.screenshot({ path: sp, fullPage: true }).catch(() => {
3395
+ });
3396
+ const cloudUrl3 = await uploadToCloud(sp, `${opts.tenantId}/${opts.projectId}/screenshots/${path.basename(sp)}`).catch(() => "");
3397
+ await AppBrain.saveScreenshot(opts.tenantId, opts.projectId, screen.id, sp, screen.version, "DESKTOP", cloudUrl3).catch(() => {
3398
+ });
3399
+ const [p3bElems, p3bLinks] = await Promise.all([
3400
+ extractElements(stagehand, page, elementLLM ?? void 0, rawPage ?? void 0).catch(() => []),
3401
+ discoverLinks(stagehand, origin, page, linkLLM ?? void 0, aiSkipCount > 0, rawPage ?? void 0).catch(() => [])
3402
+ ]);
3403
+ if (p3bElems.length > 0) {
3404
+ await AppBrain.clearScreenElements(opts.tenantId, opts.projectId, screen.id).catch(() => {
3405
+ });
3406
+ for (let _ei3 = 0; _ei3 < p3bElems.length; _ei3 += 4) {
3407
+ const _b3 = p3bElems.slice(_ei3, _ei3 + 4);
3408
+ await Promise.all(_b3.map(
3409
+ (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(() => {
3410
+ })
3411
+ ));
3412
+ }
3413
+ }
3414
+ for (const link of p3bLinks) {
3415
+ const c = canonicalize(link);
3416
+ if (isUsefulNavigationUrl(link, origin) && !queued.has(c) && !visited.has(c) && queued.size < maxScreens * 2) {
3417
+ queued.add(c);
3418
+ queue.push(link);
3419
+ }
3420
+ }
3421
+ discovered.push({ url: href, screenId: screen.id, elements: p3bElems.length, version: screen.version });
3422
+ logger.info({ url: href, elements: p3bElems.length, newLinks: p3bLinks.length }, "[P3b] Extended BFS screen captured");
3423
+ } catch (err) {
3424
+ logger.warn({ url: href, err: String(err) }, "[P3b] Extended BFS URL failed");
3425
+ visited.set(canonHref, "_error_");
3426
+ }
3427
+ }
3428
+ }
3429
+ if (visited.size >= (parseInt(process.env.CRAWLER_GAP_PASS_MIN_SCREENS ?? "") || 3)) {
2719
3430
  try {
2720
3431
  await onProgress?.(80, "Running gap analysis\u2026");
2721
3432
  const llmCfgForGap = await getCachedCrawlLLM(opts.tenantId);
@@ -2739,7 +3450,7 @@ ${snippet}`,
2739
3450
  });
2740
3451
  logger.info({ confirmed: confirmedGaps.length, total: gapCandidates.length }, "[P6] Gap pass: visiting confirmed gap URLs");
2741
3452
  const gapLoginPattern = /\/(login|signin|sign-in|auth|log-in)(\/|$|\?)/i;
2742
- for (const gap of confirmedGaps.slice(0, 8)) {
3453
+ for (const gap of confirmedGaps.slice(0, parseInt(process.env.CRAWLER_GAP_PASS_MAX_VISITS ?? "") || 8)) {
2743
3454
  const gapUrl = gap.confirmedUrl;
2744
3455
  if (visited.size >= maxScreens) break;
2745
3456
  try {
@@ -2748,16 +3459,16 @@ ${snippet}`,
2748
3459
  logger.warn({ gapUrl, landed: page.url() }, "[P6] Gap URL redirected to login \u2014 skipping");
2749
3460
  continue;
2750
3461
  }
2751
- await page.waitForTimeout(1500);
3462
+ await new Promise((r) => setTimeout(r, 1500));
2752
3463
  const gapName = gap.category;
2753
3464
  const gapScreen = await AppBrain.saveScreen(opts.tenantId, opts.projectId, gapName, gapUrl, opts.jobId, "ENTRY_POINT");
2754
3465
  const gapShot = `${opts.screenshotDir ?? "/tmp"}/${gapScreen.id}-v${gapScreen.version}.png`;
2755
- await waitForScreenshotReady(page);
3466
+ await waitForScreenshotReady(page, rawPage ?? void 0);
2756
3467
  await page.screenshot({ path: gapShot, fullPage: true });
2757
3468
  await AppBrain.saveScreenshot(opts.tenantId, opts.projectId, gapScreen.id, gapShot, gapScreen.version, "DESKTOP");
2758
3469
  const [gapElems, gapLinks, gapLinkMaps] = await Promise.all([
2759
- extractElements(stagehand, page, elementLLM ?? void 0).catch(() => []),
2760
- discoverLinks(stagehand, origin, page, linkLLM ?? void 0, false).catch(() => []),
3470
+ extractElements(stagehand, page, elementLLM ?? void 0, rawPage ?? void 0).catch(() => []),
3471
+ discoverLinks(stagehand, origin, page, linkLLM ?? void 0, false, rawPage ?? void 0).catch(() => []),
2761
3472
  extractLinkMappings(page, origin).catch(() => [])
2762
3473
  ]);
2763
3474
  for (const el of gapElems) {
@@ -2785,11 +3496,14 @@ ${snippet}`,
2785
3496
  logger.info({ total: gapCandidates.length }, "[P6] Gap pass: no confirmed gaps found");
2786
3497
  }
2787
3498
  } catch (err) {
2788
- logger.warn({ err: String(err) }, "[P6] Gap pass failed \u2014 non-fatal, continuing");
3499
+ const msg = String(err);
3500
+ logger.warn({ err: msg }, "[P6] Gap pass failed \u2014 non-fatal, continuing");
3501
+ crawlWarnings.push(`gap-analysis skipped: ${msg.includes("401") || msg.toLowerCase().includes("api key") || msg.toLowerCase().includes("authentication") ? "LLM API key invalid or missing" : msg}`);
2789
3502
  }
2790
3503
  }
2791
3504
  if (!selective && visited.size > 0) {
2792
- const discoveredUrls = Array.from(visited.keys());
3505
+ const VISIT_MARKER_RE = /^_|^pre-auth$|^skipped$/;
3506
+ const discoveredUrls = Array.from(visited.entries()).filter(([, v]) => typeof v === "string" && !VISIT_MARKER_RE.test(v)).map(([k]) => k);
2793
3507
  await AppBrain.markRemovedScreens(opts.tenantId, opts.projectId, discoveredUrls, opts.jobId);
2794
3508
  }
2795
3509
  await onProgress?.(82, "Saving app map");
@@ -2964,6 +3678,10 @@ ${snippet}`,
2964
3678
  ignoreHTTPSErrors: true,
2965
3679
  ...mainStorageState ? { storageState: mainStorageState } : {}
2966
3680
  });
3681
+ mobileCtx.on("download", (dl) => {
3682
+ dl.cancel().catch(() => {
3683
+ });
3684
+ });
2967
3685
  const mobilePage = await mobileCtx.newPage();
2968
3686
  for (const disc of discovered) {
2969
3687
  if (!disc.screenId) continue;
@@ -2976,11 +3694,13 @@ ${snippet}`,
2976
3694
  await mobilePage.addStyleTag({ content: "*, *::before, *::after { animation-duration: 0s !important; transition-duration: 0s !important; }" });
2977
3695
  } catch {
2978
3696
  }
3697
+ await infiniteScrollToBottom(mobilePage, { maxScrolls: 20, scrollDelay: 300, stabilizeMs: 400 }, mobilePage).catch(() => {
3698
+ });
2979
3699
  const shotPath = path.join(opts.screenshotDir ?? "/tmp", `${disc.screenId}-${profile.toLowerCase()}-v${disc.version}.png`);
2980
- await waitForScreenshotReady(mobilePage);
3700
+ await waitForScreenshotReady(mobilePage, mobilePage);
2981
3701
  const [, vpElements] = await Promise.all([
2982
3702
  mobilePage.screenshot({ path: shotPath, fullPage: true }),
2983
- domFallbackElements(mobilePage)
3703
+ domFallbackElements(mobilePage, mobilePage)
2984
3704
  ]);
2985
3705
  const cloudUrl = await uploadToCloud(shotPath, `${opts.tenantId}/${opts.projectId}/screenshots/${path.basename(shotPath)}`);
2986
3706
  await AppBrain.saveScreenshot(opts.tenantId, opts.projectId, disc.screenId, shotPath, disc.version, profile, cloudUrl);
@@ -3033,6 +3753,22 @@ ${snippet}`,
3033
3753
  const probeInputs = discovered.filter((d) => d.screenId).map((d) => ({ url: d.url, screenId: d.screenId, elements: d.elementFacts ?? [] }));
3034
3754
  await probeAllScreens(page, opts.tenantId, opts.projectId, probeInputs, onProgress);
3035
3755
  }
3756
+ try {
3757
+ await onProgress?.(90, "Generating state graph coverage report");
3758
+ const stateGraph = new ScreenTransitionGraph();
3759
+ const coveragePayload = {
3760
+ stateGraph: stateGraph.getCoverageReport(),
3761
+ totalScreensCrawled: discovered.filter((d) => !!d.screenId).length
3762
+ };
3763
+ AppBrain.saveCrawlEvidence(opts.tenantId, opts.projectId, {
3764
+ jobId: opts.jobId,
3765
+ type: "CRAWL_COVERAGE_REPORT",
3766
+ payload: coveragePayload
3767
+ }).catch(() => {
3768
+ });
3769
+ } catch (extErr) {
3770
+ logger.warn({ err: String(extErr) }, "[coverage-report] non-fatal error");
3771
+ }
3036
3772
  await onProgress?.(97, "Running post-crawl analysis");
3037
3773
  const successScreens = discovered.filter((d) => !!d.screenId);
3038
3774
  ;
@@ -3113,6 +3849,14 @@ ${snippet}`,
3113
3849
  const hasMoreSegments = pendingUrls.length > 0;
3114
3850
  const screensSucceeded = discovered.filter((d) => !!d.screenId).length;
3115
3851
  const screensErrored = discovered.filter((d) => !!d.error && !d.screenId).length;
3852
+ if (screensSucceeded >= 3) {
3853
+ const screensWithElements = discovered.filter((d) => (d.elements ?? 0) > 0 && d.screenId).length;
3854
+ if (screensWithElements === 0) {
3855
+ crawlWarnings.push(
3856
+ `element-extraction: 0 elements found across all ${screensSucceeded} screens \u2014 DOM evaluate may be failing (check server logs for "page.evaluate threw" warnings)`
3857
+ );
3858
+ }
3859
+ }
3116
3860
  return {
3117
3861
  screensFound: screensSucceeded,
3118
3862
  // successful captures only (not errors)
@@ -3129,8 +3873,9 @@ ${snippet}`,
3129
3873
  segmentIndex: segIdx,
3130
3874
  // which segment this was
3131
3875
  hasMoreSegments,
3132
- domHashes: Array.from(seenDomHashes)
3876
+ domHashes: Array.from(seenDomHashes),
3133
3877
  // carry forward to next segment to avoid re-screenshotting
3878
+ crawlWarnings: crawlWarnings.length > 0 ? crawlWarnings : void 0
3134
3879
  };
3135
3880
  } finally {
3136
3881
  if (stagehandPromptTokens > 0 || stagehandCompletionTokens > 0) {
@@ -3165,8 +3910,8 @@ ${snippet}`,
3165
3910
  }
3166
3911
  }
3167
3912
  const ELEMENT_NOISE = /^(cookie|consent|privacy|accept|decline|dismiss|close|copy|©|v\d|switch to (dark|light)|dark mode|light mode|toggle (dark|light|theme)|theme (toggle|switch)|share|tweet|follow|subscribe to newsletter|skip to|back to top|\s*$)/i;
3168
- async function extractElements(stagehand, page, llmCfg) {
3169
- const domElements = await domFallbackElements(page).catch((err) => {
3913
+ async function extractElements(stagehand, page, llmCfg, rawPage) {
3914
+ const domElements = await domFallbackElements(page, rawPage).catch((err) => {
3170
3915
  console.warn("[crawler] domFallbackElements failed (page context issue or DOM error):", err?.message ?? err);
3171
3916
  return [];
3172
3917
  });
@@ -3204,7 +3949,10 @@ async function extractElements(stagehand, page, llmCfg) {
3204
3949
  dynamicElements = [];
3205
3950
  }
3206
3951
  }
3207
- const contentElements = await contentFallbackElements(page);
3952
+ const contentElements = await contentFallbackElements(page).catch((err) => {
3953
+ console.warn("[crawler] contentFallbackElements failed (page context issue):", err?.message ?? err);
3954
+ return [];
3955
+ });
3208
3956
  const crossSourceDedup = (arr) => {
3209
3957
  const stripPhrases = (s) => s.replace(/^button to expand ['"]?|['"]?\s*details$/gi, "").replace(/\s+(faq\s*)?button$/i, "").replace(/^button:\s*/i, "").toLowerCase().trim().slice(0, 80);
3210
3958
  const ROLE_PRIORITY = { "faq-question": 10, "faq-answer": 9, "h1": 8, "h2": 7, "h3": 6, "h4": 5, "heading": 8, button: 3, a: 2 };
@@ -3245,9 +3993,9 @@ async function extractElements(stagehand, page, llmCfg) {
3245
3993
  return domElements;
3246
3994
  }
3247
3995
  }
3248
- async function discoverLinks(stagehand, origin, page, llmCfg, skipAi = false) {
3996
+ async function discoverLinks(stagehand, origin, page, llmCfg, skipAi = false, rawPage) {
3249
3997
  const navExpanded = await expandNavMenus(page);
3250
- const domLinks = await domFallbackLinks(page, origin);
3998
+ const domLinks = await domFallbackLinks(page, origin, rawPage);
3251
3999
  const interactiveLinks = await interactAndCollectLinks(page, origin);
3252
4000
  if (navExpanded) {
3253
4001
  page.keyboard?.press("Escape").catch(() => {
@@ -3294,8 +4042,8 @@ async function discoverLinks(stagehand, origin, page, llmCfg, skipAi = false) {
3294
4042
  return allDomLinks;
3295
4043
  }
3296
4044
  }
3297
- async function domFallbackElements(page) {
3298
- return page.evaluate(() => {
4045
+ async function domFallbackElements(page, rawPage) {
4046
+ function _browserEval() {
3299
4047
  const out = [];
3300
4048
  const seen = /* @__PURE__ */ new Set();
3301
4049
  const noise = /^(cookie|consent|privacy|accept|decline|dismiss|close|copy|©|v\d|switch to (dark|light)|dark mode|light mode|toggle (dark|light|theme)|theme (toggle|switch)|share|tweet|follow|subscribe to newsletter|skip to|back to top|\s*$)/i;
@@ -3311,7 +4059,7 @@ async function domFallbackElements(page) {
3311
4059
  }
3312
4060
  return results;
3313
4061
  };
3314
- const selector = 'input:not([type=hidden]),select,textarea,button,[role="button"],[role="checkbox"],[role="radio"],[role="combobox"],[role="slider"],[role="spinbutton"],[role="switch"],[role="tab"],[role="dialog"],[role="alertdialog"],[role="menu"],[role="menuitem"],[role="menuitemcheckbox"],[role="menuitemradio"],[role="listbox"],[role="option"],[role="treeitem"],[role="searchbox"],[role="gridcell"],[role="link"],a[role="button"],[contenteditable="true"]';
4062
+ const selector = 'input:not([type=hidden]),select,textarea,button,a[href],[role="button"],[role="checkbox"],[role="radio"],[role="combobox"],[role="slider"],[role="spinbutton"],[role="switch"],[role="tab"],[role="dialog"],[role="alertdialog"],[role="menu"],[role="menuitem"],[role="menuitemcheckbox"],[role="menuitemradio"],[role="listbox"],[role="option"],[role="treeitem"],[role="searchbox"],[role="gridcell"],[role="link"],[contenteditable="true"]';
3315
4063
  querySelectorAllDeep(document, selector).forEach((el) => {
3316
4064
  const style = window.getComputedStyle(el);
3317
4065
  if (style.display === "none" || style.visibility === "hidden" || style.opacity === "0" || style.pointerEvents === "none") return;
@@ -3323,11 +4071,10 @@ async function domFallbackElements(page) {
3323
4071
  const tag = el.tagName.toLowerCase();
3324
4072
  const ariaRole = el.getAttribute("role");
3325
4073
  const isActionEl = tag === "button" || tag === "a" || ariaRole === "button";
3326
- if (tag === "a" || ariaRole === "button" && tag !== "button") {
4074
+ if (tag === "a") {
3327
4075
  let p = el.parentElement;
3328
4076
  while (p) {
3329
- const ptag = p.tagName?.toLowerCase();
3330
- if (["header", "footer"].includes(ptag)) return;
4077
+ if (p.tagName?.toLowerCase() === "footer") return;
3331
4078
  p = p.parentElement;
3332
4079
  }
3333
4080
  }
@@ -3472,7 +4219,48 @@ async function domFallbackElements(page) {
3472
4219
  const priority = (e) => ["email", "password", "text", "tel", "date", "number", "select", "textarea", "url", "file"].includes(e.role) ? 0 : 1;
3473
4220
  out.sort((a, b) => priority(a) - priority(b));
3474
4221
  return out;
3475
- }).then((elems) => elems.map((e) => ({ ...e, isInteractive: true }))).catch(() => []);
4222
+ }
4223
+ const _browserEvalStr = `(function(){const __name=(fn,n)=>{try{Object.defineProperty(fn,'name',{value:n,configurable:true})}catch(e){}return fn};return(${_browserEval.toString()})()})();`;
4224
+ if (rawPage && typeof rawPage.context === "function") {
4225
+ try {
4226
+ const ctx = rawPage.context();
4227
+ if (typeof ctx?.newCDPSession === "function") {
4228
+ const cdp = await ctx.newCDPSession(rawPage);
4229
+ try {
4230
+ const evalResult = await cdp.send("Runtime.evaluate", {
4231
+ expression: _browserEvalStr,
4232
+ returnByValue: true,
4233
+ awaitPromise: false
4234
+ });
4235
+ if (evalResult?.exceptionDetails) {
4236
+ console.warn("[crawler] domFallbackElements CDP primary error:", evalResult.exceptionDetails?.exception?.description ?? evalResult.exceptionDetails?.text);
4237
+ } else if (Array.isArray(evalResult?.result?.value)) {
4238
+ return evalResult.result.value.map((e) => ({ ...e, isInteractive: true }));
4239
+ }
4240
+ } finally {
4241
+ cdp.detach().catch(() => {
4242
+ });
4243
+ }
4244
+ }
4245
+ } catch (cdpPrimaryErr) {
4246
+ console.warn("[crawler] domFallbackElements CDP primary failed, trying page.evaluate:", cdpPrimaryErr?.message ?? String(cdpPrimaryErr));
4247
+ }
4248
+ }
4249
+ const evalPage = rawPage ?? page;
4250
+ const _doEvaluate = () => evalPage.evaluate(_browserEvalStr);
4251
+ let result;
4252
+ try {
4253
+ result = await _doEvaluate();
4254
+ } catch (err1) {
4255
+ await new Promise((r) => setTimeout(r, 500));
4256
+ try {
4257
+ result = await _doEvaluate();
4258
+ } catch (err2) {
4259
+ console.warn("[crawler] domFallbackElements page.evaluate threw (attempt 2):", err2?.message ?? String(err2));
4260
+ return [];
4261
+ }
4262
+ }
4263
+ return (result ?? []).map((e) => ({ ...e, isInteractive: true }));
3476
4264
  }
3477
4265
  async function contentFallbackElements(page) {
3478
4266
  return page.evaluate(() => {
@@ -4272,8 +5060,8 @@ async function expandNavMenus(page) {
4272
5060
  return false;
4273
5061
  }
4274
5062
  }
4275
- async function domFallbackLinks(page, origin) {
4276
- return page.evaluate((origin2) => {
5063
+ async function domFallbackLinks(page, origin, rawPage) {
5064
+ function _linkEval(originStr) {
4277
5065
  const logout = /logout|log out|sign out|delete/i;
4278
5066
  const isRouteHash = (hash) => /^#!?\//.test(hash);
4279
5067
  const seen = /* @__PURE__ */ new Set();
@@ -4285,7 +5073,7 @@ async function domFallbackLinks(page, origin) {
4285
5073
  try {
4286
5074
  const u = new URL(rawHref, location.href);
4287
5075
  if (!isRouteHash(u.hash)) u.hash = "";
4288
- if (new URL(u).origin === origin2) seen.add(u.toString());
5076
+ if (new URL(u).origin === originStr) seen.add(u.toString());
4289
5077
  } catch {
4290
5078
  }
4291
5079
  };
@@ -4316,7 +5104,39 @@ async function domFallbackLinks(page, origin) {
4316
5104
  }
4317
5105
  }
4318
5106
  return Array.from(seen);
4319
- }, origin).catch(() => []);
5107
+ }
5108
+ const _linkEvalStr = `(function(){const __name=(fn,n)=>{try{Object.defineProperty(fn,'name',{value:n,configurable:true})}catch(e){}return fn};return(${_linkEval.toString()})(${JSON.stringify(origin)})})();`;
5109
+ if (rawPage && typeof rawPage.context === "function") {
5110
+ try {
5111
+ const ctx = rawPage.context();
5112
+ if (typeof ctx?.newCDPSession === "function") {
5113
+ const cdp = await ctx.newCDPSession(rawPage);
5114
+ try {
5115
+ const evalResult = await cdp.send("Runtime.evaluate", {
5116
+ expression: _linkEvalStr,
5117
+ returnByValue: true,
5118
+ awaitPromise: false
5119
+ });
5120
+ if (!evalResult?.exceptionDetails && Array.isArray(evalResult?.result?.value)) {
5121
+ return evalResult.result.value;
5122
+ }
5123
+ if (evalResult?.exceptionDetails) {
5124
+ console.warn("[crawler] domFallbackLinks CDP error:", evalResult.exceptionDetails?.exception?.description ?? evalResult.exceptionDetails?.text);
5125
+ }
5126
+ } finally {
5127
+ cdp.detach().catch(() => {
5128
+ });
5129
+ }
5130
+ }
5131
+ } catch (cdpErr) {
5132
+ console.warn("[crawler] domFallbackLinks CDP failed:", cdpErr?.message ?? String(cdpErr));
5133
+ }
5134
+ }
5135
+ const evalPage = rawPage ?? page;
5136
+ return evalPage.evaluate(_linkEvalStr).catch((err) => {
5137
+ console.warn("[crawler] domFallbackLinks page.evaluate threw:", err?.message ?? String(err));
5138
+ return [];
5139
+ });
4320
5140
  }
4321
5141
  async function extractLinkMappings(page, origin) {
4322
5142
  return page.evaluate((origin2) => {
@@ -4417,50 +5237,105 @@ async function dismissOverlays(page) {
4417
5237
  } catch {
4418
5238
  }
4419
5239
  }
4420
- await page.waitForTimeout(300).catch(() => {
4421
- });
5240
+ await new Promise((r) => setTimeout(r, 300));
4422
5241
  }
4423
- async function scrollToTriggerLazyLoad(page) {
5242
+ async function scrollToTriggerLazyLoad(page, rawPage) {
5243
+ const evalPage = rawPage ?? page;
4424
5244
  try {
4425
- await page.evaluate(async () => {
4426
- const step = Math.max(window.innerHeight, 400);
4427
- const total = Math.min(document.body.scrollHeight, 12e3);
4428
- for (let y = step; y < total; y += step) {
4429
- window.scrollTo(0, y);
4430
- await new Promise((r) => setTimeout(r, 120));
5245
+ const { scrollHeight, innerHeight, innerWidth } = await evalPage.evaluate(() => ({
5246
+ scrollHeight: Math.min(document.body.scrollHeight, 12e3),
5247
+ innerHeight: window.innerHeight || 800,
5248
+ innerWidth: window.innerWidth || 1280
5249
+ })).catch(() => ({ scrollHeight: 0, innerHeight: 800, innerWidth: 1280 }));
5250
+ if (scrollHeight > innerHeight) {
5251
+ let cdp = null;
5252
+ if (rawPage && typeof rawPage.context === "function") {
5253
+ try {
5254
+ const ctx = rawPage.context();
5255
+ if (typeof ctx?.newCDPSession === "function") cdp = await ctx.newCDPSession(rawPage);
5256
+ } catch {
5257
+ cdp = null;
5258
+ }
4431
5259
  }
4432
- window.scrollTo(0, 0);
4433
- });
4434
- await page.waitForTimeout(400).catch(() => {
4435
- });
4436
- await waitForStableDOM(page, 3e3, 300);
5260
+ const step = Math.max(innerHeight, 400);
5261
+ for (let y = step; y < scrollHeight; y += step) {
5262
+ await evalPage.evaluate((pos) => window.scrollTo(0, pos), y).catch(() => {
5263
+ });
5264
+ if (cdp) {
5265
+ await cdp.send("Input.dispatchMouseEvent", {
5266
+ type: "mouseWheel",
5267
+ x: Math.floor(innerWidth / 2),
5268
+ y: Math.floor(innerHeight / 2),
5269
+ deltaX: 0,
5270
+ deltaY: step,
5271
+ modifiers: 0
5272
+ }).catch(() => {
5273
+ });
5274
+ }
5275
+ await new Promise((r) => setTimeout(r, 150));
5276
+ }
5277
+ await evalPage.evaluate(() => window.scrollTo(0, 0)).catch(() => {
5278
+ });
5279
+ if (cdp) cdp.detach().catch(() => {
5280
+ });
5281
+ }
5282
+ await new Promise((r) => setTimeout(r, 400));
5283
+ const scrollWaitPage = rawPage ?? page;
5284
+ if (typeof scrollWaitPage.waitForLoadState === "function") {
5285
+ await scrollWaitPage.waitForLoadState("networkidle", { timeout: 5e3 }).catch(() => {
5286
+ });
5287
+ }
5288
+ await waitForStableDOM(page, 4e3, 500, rawPage);
4437
5289
  } catch {
4438
5290
  }
4439
5291
  }
4440
- async function waitForStableDOM(page, maxWait = 8e3, stabilityMs = 400) {
5292
+ async function waitForStableDOM(page, maxWait = 8e3, stabilityMs = 400, rawPage) {
5293
+ const evalPage = rawPage ?? page;
4441
5294
  const deadline = Date.now() + maxWait;
4442
5295
  let lastCount = -1;
4443
5296
  let stableAt = 0;
5297
+ let evalErrors = 0;
4444
5298
  while (Date.now() < deadline) {
4445
- const count = await page.evaluate(() => document.querySelectorAll("*").length).catch(() => 0);
4446
- if (count !== lastCount) {
4447
- lastCount = count;
4448
- stableAt = Date.now();
4449
- } else if (Date.now() - stableAt >= stabilityMs) {
4450
- return;
5299
+ const count = await evalPage.evaluate(() => {
5300
+ let c = 0;
5301
+ const visit = (root) => {
5302
+ const els = root.querySelectorAll("*");
5303
+ c += els.length;
5304
+ els.forEach((el) => {
5305
+ if (el.shadowRoot) visit(el.shadowRoot);
5306
+ });
5307
+ };
5308
+ visit(document);
5309
+ return c;
5310
+ }).catch(() => -1);
5311
+ if (count === -1) {
5312
+ if (++evalErrors >= 3) {
5313
+ await new Promise((r) => setTimeout(r, stabilityMs));
5314
+ return;
5315
+ }
5316
+ } else {
5317
+ evalErrors = 0;
5318
+ if (count !== lastCount) {
5319
+ lastCount = count;
5320
+ stableAt = Date.now();
5321
+ } else if (Date.now() - stableAt >= stabilityMs) {
5322
+ return;
5323
+ }
4451
5324
  }
4452
5325
  await new Promise((r) => setTimeout(r, 100));
4453
5326
  }
4454
5327
  }
4455
- async function waitForScreenshotReady(page) {
4456
- const hardDeadline = Date.now() + 2e4;
5328
+ async function waitForScreenshotReady(page, rawPage) {
5329
+ const hardDeadline = Date.now() + 3e4;
4457
5330
  const withHardCap = (p) => Promise.race([p, new Promise((resolve) => setTimeout(resolve, Math.max(0, hardDeadline - Date.now())))]);
5331
+ const waitPage = rawPage ?? page;
5332
+ const evalPage = rawPage ?? page;
4458
5333
  try {
4459
- await withHardCap(page.waitForLoadState("domcontentloaded", { timeout: 5e3 }).catch(() => {
5334
+ await withHardCap(waitPage.waitForLoadState("domcontentloaded", { timeout: 5e3 }).catch(() => {
4460
5335
  }));
4461
- await withHardCap(page.waitForLoadState("networkidle", { timeout: 12e3 }).catch(() => {
5336
+ await withHardCap(waitPage.waitForLoadState("networkidle", { timeout: 12e3 }).catch(() => {
4462
5337
  }));
4463
- await withHardCap(page.evaluate(() => {
5338
+ await withHardCap(evalPage.evaluate(() => {
4464
5339
  if (document.readyState === "complete") return true;
4465
5340
  return new Promise((resolve) => {
4466
5341
  const done = () => resolve(true);
@@ -4469,14 +5344,14 @@ async function waitForScreenshotReady(page) {
4469
5344
  });
4470
5345
  }).catch(() => {
4471
5346
  }));
4472
- await withHardCap(page.evaluate(async () => {
5347
+ await withHardCap(evalPage.evaluate(async () => {
4473
5348
  const fonts = document.fonts;
4474
5349
  if (fonts?.ready) await fonts.ready;
4475
5350
  }).catch(() => {
4476
5351
  }));
4477
- await withHardCap(scrollToTriggerLazyLoad(page));
4478
- await withHardCap(waitForStableDOM(page, 4e3, 700));
4479
- await withHardCap(page.evaluate(async () => {
5352
+ await withHardCap(scrollToTriggerLazyLoad(rawPage ?? page, rawPage));
5353
+ await withHardCap(waitForStableDOM(page, 4e3, 700, rawPage));
5354
+ await withHardCap(evalPage.evaluate(async () => {
4480
5355
  const pending = Array.from(document.images).filter((img) => !img.complete);
4481
5356
  if (pending.length === 0) return;
4482
5357
  await Promise.race([
@@ -4488,7 +5363,7 @@ async function waitForScreenshotReady(page) {
4488
5363
  ]);
4489
5364
  }).catch(() => {
4490
5365
  }));
4491
- await withHardCap(waitForStableDOM(page, 2e3, 500));
5366
+ await withHardCap(waitForStableDOM(page, 2e3, 500, rawPage));
4492
5367
  } catch {
4493
5368
  }
4494
5369
  }
@@ -4642,6 +5517,7 @@ async function captureAuthDiscoveryScreen(page, opts, requestedUrl, source, ordi
4642
5517
  return { captured: true, finalUrl, flowType };
4643
5518
  }
4644
5519
  async function runAuthDiscoveryPhase(page, opts, origin, onProgress) {
5520
+ const tuning = await getCrawlTuning();
4645
5521
  const queue = [];
4646
5522
  const queued = /* @__PURE__ */ new Set();
4647
5523
  const capturedKeys = /* @__PURE__ */ new Set();
@@ -4677,8 +5553,11 @@ async function runAuthDiscoveryPhase(page, opts, origin, onProgress) {
4677
5553
  await onProgress?.(10, "Discovering public and auth flows");
4678
5554
  while (queue.length > 0 && captured < AUTH_DISCOVERY_MAX_SCREENS) {
4679
5555
  const current = queue.shift();
5556
+ const _authPct = 10 + Math.round(captured / AUTH_DISCOVERY_MAX_SCREENS * 7);
5557
+ const _authPath = current.url.replace(/^https?:\/\/[^/]+/, "") || "/";
5558
+ await onProgress?.(_authPct, `Auth discovery \xB7 ${_authPath} \xB7 ${captured} found`);
4680
5559
  try {
4681
- const response = await page.goto(current.url, { waitUntil: "domcontentloaded", timeout: 15e3 }).catch(() => null);
5560
+ const response = await page.goto(current.url, { waitUntil: "domcontentloaded", timeout: tuning.navTimeoutMs }).catch(() => null);
4682
5561
  if (response && response.status && response.status() >= 400) continue;
4683
5562
  await page.waitForLoadState("networkidle", { timeout: 6e3 }).catch(() => {
4684
5563
  });