@detiq/crawler 1.1.14 → 1.1.16

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 (110) 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 +1377 -344
  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 +23 -6
  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 +1196 -150
  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/job-queue.ts +9 -0
  74. package/src/locator-extractor.ts +45 -0
  75. package/src/multi-context-handler.ts +296 -0
  76. package/src/nested-window-crawler.ts +310 -0
  77. package/src/network-action-correlator.ts +320 -0
  78. package/src/performance-metrics-collector.ts +243 -0
  79. package/src/performance-metrics.ts +1 -1
  80. package/src/screen-reader.ts +4 -1
  81. package/src/service-worker-interceptor.ts +185 -0
  82. package/src/session-capture.ts +3 -0
  83. package/src/shadow-dom-crawler.ts +198 -0
  84. package/src/state-graph.ts +280 -0
  85. package/src/stealth/human-behavior.ts +38 -4
  86. package/src/url-pattern-completer.ts +61 -15
  87. package/tsconfig.json +1 -1
  88. package/types/accessibility-tree-crawler.d.ts +44 -0
  89. package/types/advanced-ui-probes.d.ts +47 -0
  90. package/types/crawl-session-manager.d.ts +44 -0
  91. package/types/crawler.d.ts +3 -1
  92. package/types/crud-interaction.d.ts +31 -0
  93. package/types/dropdown-enumerator.d.ts +20 -0
  94. package/types/filter-pagination-crawler.d.ts +52 -0
  95. package/types/gap-pass.d.ts +1 -0
  96. package/types/index.d.ts +14 -1
  97. package/types/job-queue.d.ts +18 -9
  98. package/types/locator-extractor.d.ts +7 -0
  99. package/types/multi-context-handler.d.ts +55 -0
  100. package/types/nested-window-crawler.d.ts +41 -0
  101. package/types/network-action-correlator.d.ts +47 -0
  102. package/types/performance-metrics-collector.d.ts +50 -0
  103. package/types/performance-metrics.d.ts +1 -2
  104. package/types/screen-reader.d.ts +1 -2
  105. package/types/service-worker-interceptor.d.ts +36 -0
  106. package/types/session-capture.d.ts +1 -1
  107. package/types/shadow-dom-crawler.d.ts +35 -0
  108. package/types/sitemap.d.ts +2 -2
  109. package/types/state-graph.d.ts +59 -0
  110. 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
+ });
714
795
  });
796
+ pwContext.on("download", (dl) => {
797
+ dl.cancel().catch(() => {
798
+ });
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,85 @@ 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 AUTH_WALL_LABEL_RE = /^(sign.?in|log.?in|login|platform access|back\s+to|checking\s+auth|get started|create account|register|continue with|join now)/i;
955
+ const allAnchorOnly = preAuthElements.every((el) => el.role === "a");
956
+ const looksLikeAuthWall = preAuthElements.length <= 3 && (allAnchorOnly || preAuthElements.every((el) => AUTH_WALL_LABEL_RE.test(el.label)));
957
+ if (looksLikeAuthWall) {
958
+ logger.info({ url: finalUrl, elements: preAuthElements.length, labels: preAuthElements.map((e) => e.label) }, "[crawler] pre-auth auth wall \u2014 deferring to authenticated BFS");
959
+ continue;
960
+ }
961
+ const pathParts = finalPath.split("/").filter(Boolean);
962
+ const screenName = pathParts.map((p) => p.charAt(0).toUpperCase() + p.slice(1).replace(/[-_]/g, " ")).join(" \u2014 ") || "Home";
963
+ const screen = await AppBrain.saveScreen(opts.tenantId, opts.projectId, screenName, finalUrl, opts.jobId, "LINK_FOLLOW").catch(() => null);
964
+ if (!screen) continue;
965
+ const shotPath = path.join(opts.screenshotDir ?? "/tmp", `${screen.id}-desktop-v${screen.version}.png`);
966
+ await page.screenshot({ path: shotPath, fullPage: true }).catch(() => {
967
+ });
968
+ const cloudUrl = await uploadToCloud(shotPath, `${opts.tenantId}/${opts.projectId}/screenshots/${path.basename(shotPath)}`).catch(() => void 0);
969
+ await AppBrain.saveScreenshot(opts.tenantId, opts.projectId, screen.id, shotPath, screen.version, "DESKTOP", cloudUrl).catch(() => {
970
+ });
971
+ await AppBrain.clearScreenElements(opts.tenantId, opts.projectId, screen.id).catch(() => {
972
+ });
973
+ for (const el of preAuthElements) {
974
+ 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(() => {
975
+ });
976
+ }
977
+ preAuthPublicCaptured.add(finalUrl);
978
+ logger.info({ url: finalUrl, screenId: screen.id, elements: preAuthElements.length }, "[crawler] pre-auth public page captured");
979
+ } catch (err) {
980
+ logger.warn({ url: link, err: err?.message }, "[crawler] pre-auth public page visit failed \u2014 skipping");
981
+ }
982
+ }
983
+ if (preAuthPublicCaptured.size > 0) {
984
+ await page.goto(opts.appUrl, { waitUntil: "domcontentloaded", timeout: 2e4 }).catch(() => {
985
+ });
986
+ await page.waitForLoadState("networkidle", { timeout: 8e3 }).catch(() => {
987
+ });
988
+ }
989
+ }
802
990
  if (!opts.discoveryMode) {
803
991
  try {
804
992
  await runAuthDiscoveryPhase(page, opts, new URL(opts.appUrl).origin, onProgress);
@@ -806,6 +994,16 @@ async function crawlProject(opts, onProgress) {
806
994
  logger.warn({ err: preAuthErr }, "[crawler] auth-discovery phase failed \u2014 continuing with authenticated crawl");
807
995
  }
808
996
  }
997
+ {
998
+ const worldOk = await page.evaluate(() => true).catch(() => false);
999
+ if (!worldOk) {
1000
+ logger.warn("[crawler] isolated world corrupted after auth discovery \u2014 re-navigating to recover");
1001
+ await page.goto(opts.appUrl, { waitUntil: "domcontentloaded", timeout: 15e3 }).catch(() => {
1002
+ });
1003
+ await page.waitForLoadState("networkidle", { timeout: 6e3 }).catch(() => {
1004
+ });
1005
+ }
1006
+ }
809
1007
  let didLogin = false;
810
1008
  if (opts.authToken) {
811
1009
  await onProgress?.(18, "API token configured \u2014 skipping login");
@@ -819,8 +1017,42 @@ async function crawlProject(opts, onProgress) {
819
1017
  if (browserCtx) {
820
1018
  await browserCtx.addCookies(state.cookies);
821
1019
  }
1020
+ try {
1021
+ const stagehandCtx = page.context?.();
1022
+ if (stagehandCtx && typeof stagehandCtx.addCookies === "function") {
1023
+ await stagehandCtx.addCookies(state.cookies);
1024
+ }
1025
+ } catch {
1026
+ }
822
1027
  }
823
1028
  if (state.origins?.length) {
1029
+ const originCtx = playwrightBrowser?.contexts()[0];
1030
+ if (originCtx) {
1031
+ await originCtx.addInitScript((origins) => {
1032
+ for (const entry of origins ?? []) {
1033
+ if (window.location.origin === entry.origin) {
1034
+ for (const item of entry.localStorage) {
1035
+ try {
1036
+ localStorage.setItem(item.name, item.value);
1037
+ } catch {
1038
+ }
1039
+ }
1040
+ }
1041
+ }
1042
+ }, state.origins);
1043
+ }
1044
+ await page.addInitScript((origins) => {
1045
+ for (const entry of origins ?? []) {
1046
+ if (window.location.origin === entry.origin) {
1047
+ for (const item of entry.localStorage) {
1048
+ try {
1049
+ localStorage.setItem(item.name, item.value);
1050
+ } catch {
1051
+ }
1052
+ }
1053
+ }
1054
+ }
1055
+ }, state.origins);
824
1056
  await rawPage.addInitScript((origins) => {
825
1057
  for (const entry of origins ?? []) {
826
1058
  if (window.location.origin === entry.origin) {
@@ -1024,12 +1256,43 @@ async function crawlProject(opts, onProgress) {
1024
1256
  "nonce",
1025
1257
  "session",
1026
1258
  "sid",
1259
+ // App content/state params — these change the content rendered within the same
1260
+ // page template (a playground example, a search query, a tab selection) but do NOT
1261
+ // represent structurally distinct pages. Not stripping them causes URL explosion:
1262
+ // every unique ?cmd=DSN-XXXXX&example=... or ?q=foo is queued as a new screen.
1263
+ "cmd",
1264
+ "example",
1265
+ "query",
1266
+ "q",
1267
+ "search",
1268
+ "keyword",
1269
+ "keywords",
1270
+ "tab",
1271
+ "view",
1272
+ "mode",
1273
+ "sort",
1274
+ "order",
1275
+ "direction",
1276
+ "orderby",
1277
+ "page",
1278
+ "p",
1279
+ "offset",
1280
+ "limit",
1281
+ "per_page",
1282
+ "size",
1283
+ "rows",
1284
+ // Export format params — same endpoint, different output format, not a new screen
1285
+ "format",
1286
+ "output",
1287
+ "export_format",
1288
+ "download_format",
1289
+ "fileformat",
1290
+ "file_format",
1027
1291
  // WooCommerce / e-commerce action params
1028
1292
  "add-to-cart",
1029
1293
  "add_to_wishlist",
1030
1294
  "quantity",
1031
1295
  "wc-ajax",
1032
- "orderby",
1033
1296
  "min_price",
1034
1297
  "max_price",
1035
1298
  "removed_item",
@@ -1097,9 +1360,11 @@ async function crawlProject(opts, onProgress) {
1097
1360
  };
1098
1361
  const gitInfo = getGitInfo();
1099
1362
  const visited = /* @__PURE__ */ new Map();
1363
+ const crawlWarnings = [];
1100
1364
  if (opts.skipUrls?.length) {
1101
1365
  for (const u of opts.skipUrls) visited.set(canonicalize(u), "skipped");
1102
1366
  }
1367
+ for (const u of preAuthPublicCaptured) visited.set(canonicalize(u), "pre-auth");
1103
1368
  let checkpointSuccessCount = 0;
1104
1369
  let maxSeenTotal = 0;
1105
1370
  let lastEmittedPct = 20;
@@ -1133,7 +1398,7 @@ async function crawlProject(opts, onProgress) {
1133
1398
  ];
1134
1399
  const queued = new Set(seedUrls.map(canonicalize));
1135
1400
  const queue = new CrawlFrontier(opts.traversalStrategy ?? "BFS", seedUrls, opts.bestFirstKeywords, opts.urlFilter);
1136
- const deduplicator = new ContentDeduplicator(0.88);
1401
+ const deduplicator = new ContentDeduplicator(parseFloat(process.env.CRAWLER_DEDUP_THRESHOLD ?? "") || 0.88);
1137
1402
  const deadLinkTracker = new DeadLinkTracker();
1138
1403
  const redirectRegistry = new RedirectRegistry();
1139
1404
  if (!selective && resumeUrls.length === 0) {
@@ -1143,6 +1408,7 @@ async function crawlProject(opts, onProgress) {
1143
1408
  const orphanIdSet = new Set(
1144
1409
  (orphanReport?.screens ?? []).filter((s) => s.classification !== "REACHABLE").map((s) => s.id)
1145
1410
  );
1411
+ const seenReseedCanon = new Set(queued);
1146
1412
  const priorScreens = (priorMap?.screens ?? []).filter((s) => s.status !== "PENDING_REMOVAL").filter((s) => {
1147
1413
  const u = s.url;
1148
1414
  if (!u) return false;
@@ -1151,11 +1417,15 @@ async function crawlProject(opts, onProgress) {
1151
1417
  const stripWww = (h) => h.replace(/^www\./i, "").toLowerCase();
1152
1418
  if (stripWww(new URL(u).hostname) !== stripWww(new URL(startUrl).hostname)) return false;
1153
1419
  if (isGeneratedDynamicPath(pathname)) return false;
1420
+ if (!isUsefulNavigationUrl(u, startUrl)) return false;
1421
+ const canon = canonicalize(u);
1422
+ if (seenReseedCanon.has(canon)) return false;
1423
+ seenReseedCanon.add(canon);
1154
1424
  return true;
1155
1425
  } catch {
1156
1426
  return false;
1157
1427
  }
1158
- }).filter((s) => !queued.has(canonicalize(s.url)));
1428
+ });
1159
1429
  let orphanSeedCount = 0;
1160
1430
  for (const s of priorScreens) {
1161
1431
  const u = s.url;
@@ -1213,6 +1483,7 @@ async function crawlProject(opts, onProgress) {
1213
1483
  try {
1214
1484
  const u = new URL(sUrl);
1215
1485
  if (u.origin !== sitemapOrigin) continue;
1486
+ if (API_PATH_RE.test(u.pathname) || EXPORT_PATH_RE.test(u.pathname) || DOWNLOAD_EXT_RE.test(u.pathname)) continue;
1216
1487
  await page.goto(sUrl, { waitUntil: "domcontentloaded", timeout: 12e3 });
1217
1488
  await page.waitForLoadState("networkidle", { timeout: 5e3 }).catch(() => {
1218
1489
  });
@@ -1268,16 +1539,20 @@ async function crawlProject(opts, onProgress) {
1268
1539
  const discovered = [];
1269
1540
  const performanceMetrics = [];
1270
1541
  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;
1542
+ const skipPathExtra = process.env.CRAWL_SKIP_PATH_EXTRA ? process.env.CRAWL_SKIP_PATH_EXTRA.split(",").map((p) => p.trim()).filter(Boolean) : [];
1543
+ 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
1544
  const segIdx = opts.segmentIndex ?? 1;
1273
1545
  const robotsCache = opts.respectRobotsTxt ? new RobotsCache() : null;
1274
1546
  let avgNavMs = 0;
1275
1547
  let navCount = 0;
1276
1548
  let aiErrorStreak = 0;
1277
1549
  let aiSkipCount = 0;
1550
+ const aiMinElements = parseInt(process.env.CRAWL_AI_LINK_MIN_ELEMENTS ?? "") || 5;
1278
1551
  let aiRateLimitWarned = false;
1279
1552
  let totalAttemptsThisSegment = 0;
1280
1553
  const maxAttempts = Math.max(segmentSize * 4, 200);
1554
+ let loginWallRedirectCount = 0;
1555
+ const LOGIN_WALL_ABORT_THRESHOLD = Math.max(8, Math.ceil(segmentSize * 0.4));
1281
1556
  let pendingSessionRestore = null;
1282
1557
  const CRAWL_PAGE_PARALLELISM = Math.max(1, parseInt(process.env.CRAWL_PAGE_PARALLELISM ?? "5", 10));
1283
1558
  async function runLiteWorker(workerPage) {
@@ -1291,18 +1566,42 @@ async function crawlProject(opts, onProgress) {
1291
1566
  try {
1292
1567
  const { pathname } = new URL(url);
1293
1568
  if (SKIP_PATH.test(pathname) || isGeneratedDynamicPath(pathname)) continue;
1569
+ if (API_PATH_RE.test(pathname) || EXPORT_PATH_RE.test(pathname) || DOWNLOAD_EXT_RE.test(pathname)) continue;
1294
1570
  } catch {
1295
1571
  continue;
1296
1572
  }
1297
1573
  visited.set(canonUrl, "_pending_");
1298
1574
  try {
1299
- const navErr = await workerPage.goto(url, { waitUntil: "domcontentloaded", timeout: 15e3 }).then(() => null, (e) => e);
1575
+ const navErr = await workerPage.goto(url, { waitUntil: "domcontentloaded", timeout: tuning.navTimeoutMs }).then(() => null, (e) => e);
1300
1576
  if (navErr && /timeout/i.test(String(navErr?.message ?? navErr))) {
1301
1577
  await workerPage.goto(url, { waitUntil: "domcontentloaded", timeout: 3e4 }).catch(() => {
1302
1578
  });
1303
1579
  }
1304
1580
  await workerPage.waitForLoadState("networkidle", { timeout: 8e3 }).catch(() => {
1305
1581
  });
1582
+ await waitForStableDOM(workerPage, 6e3, 400).catch(() => {
1583
+ });
1584
+ await dismissOverlays(workerPage).catch(() => {
1585
+ });
1586
+ const lwCurrentUrl = workerPage.url();
1587
+ const lwPath = (() => {
1588
+ try {
1589
+ return new URL(lwCurrentUrl).pathname.toLowerCase();
1590
+ } catch {
1591
+ return "";
1592
+ }
1593
+ })();
1594
+ const lwIsAuthWall = /^\/(login|signin|sign-in|auth|authenticate|session\/new|account\/login|user\/login)/.test(lwPath) && lwCurrentUrl !== url;
1595
+ const lwHasLoginForm = lwIsAuthWall ? false : await workerPage.evaluate(() => {
1596
+ const inputs = document.querySelectorAll('input[type="password"]');
1597
+ return inputs.length > 0;
1598
+ }).catch(() => false);
1599
+ if (lwIsAuthWall) {
1600
+ logger.warn({ url, landed: lwCurrentUrl }, "[lite-worker] auth wall detected \u2014 queuing for main worker re-auth");
1601
+ visited.set(canonUrl, "_auth_wall_");
1602
+ if (!revisitQueue.includes(url)) revisitQueue.push(url);
1603
+ continue;
1604
+ }
1306
1605
  if (homeFingerprintElemCount >= 0 && url !== startUrl) {
1307
1606
  const wTitle = await workerPage.title().catch(() => "");
1308
1607
  if (wTitle === homeFingerprintTitle && wTitle !== "") {
@@ -1321,31 +1620,228 @@ async function crawlProject(opts, onProgress) {
1321
1620
  const screen = await AppBrain.saveScreen(opts.tenantId, opts.projectId, name, url, opts.jobId, discoveryMethodFor(url));
1322
1621
  visited.set(canonUrl, screen.id);
1323
1622
  const shotPath = path.join(opts.screenshotDir ?? "/tmp", `${screen.id}-desktop-v${screen.version}.png`);
1623
+ let liteElements = await domFallbackElements(workerPage).catch(() => []);
1624
+ if (liteElements.length === 0) {
1625
+ liteElements = await workerPage.evaluate(() => {
1626
+ const sel = 'button,a[href],input,select,textarea,[role],[tabindex="0"],[onclick],[data-action]';
1627
+ return Array.from(document.querySelectorAll(sel)).filter((el) => el.offsetParent !== null).slice(0, 150).map((el) => ({
1628
+ meaning: el.getAttribute("aria-label") || el.textContent?.trim().slice(0, 100) || el.tagName.toLowerCase(),
1629
+ role: el.getAttribute("role") || el.tagName.toLowerCase(),
1630
+ expectedData: null,
1631
+ notes: null,
1632
+ boundingRect: null,
1633
+ ariaState: null,
1634
+ parentLandmark: null
1635
+ }));
1636
+ }).catch(() => []);
1637
+ }
1324
1638
  const [elements] = await Promise.all([
1325
- domFallbackElements(workerPage).catch(() => []),
1639
+ Promise.resolve(liteElements),
1326
1640
  workerPage.screenshot({ path: shotPath, fullPage: true }).then(async () => {
1327
1641
  const cloudUrl = await uploadToCloud(shotPath, `${opts.tenantId}/${opts.projectId}/screenshots/${path.basename(shotPath)}`);
1328
1642
  return AppBrain.saveScreenshot(opts.tenantId, opts.projectId, screen.id, shotPath, screen.version, "DESKTOP", cloudUrl);
1329
1643
  }).catch(() => {
1330
1644
  })
1331
1645
  ]);
1646
+ const _navEls = await workerPage.evaluate(() => {
1647
+ if (typeof globalThis.__name === "undefined") globalThis.__name = (fn) => fn;
1648
+ const seen = /* @__PURE__ */ new Set();
1649
+ const items = [];
1650
+ function isV(el) {
1651
+ const s = window.getComputedStyle(el);
1652
+ return s.display !== "none" && s.visibility !== "hidden" && s.opacity !== "0";
1653
+ }
1654
+ const NAV_SEL = [
1655
+ "nav a",
1656
+ '[role="navigation"] a',
1657
+ "header a",
1658
+ "footer nav a",
1659
+ '[aria-label*="navigation" i] a',
1660
+ '[aria-label*="sidebar" i] a',
1661
+ "aside a",
1662
+ '[role="complementary"] a',
1663
+ '[class*="sidebar" i] a',
1664
+ '[class*="side-nav" i] a',
1665
+ '[class*="sidenav" i] a',
1666
+ '[class*="nav-menu" i] a',
1667
+ '[class*="navmenu" i] a',
1668
+ "[data-nav] a"
1669
+ ].join(", ");
1670
+ document.querySelectorAll(NAV_SEL).forEach((a) => {
1671
+ if (!isV(a)) return;
1672
+ const label = (a.getAttribute("aria-label") || a.textContent?.replace(/\s+/g, " ").trim() || "").slice(0, 60);
1673
+ const href = a.href ?? "";
1674
+ if (!label || !href || /^(javascript:|#$)/i.test(href)) return;
1675
+ let landmark = "Site Navigation";
1676
+ let cur = a.parentElement;
1677
+ while (cur) {
1678
+ const t = cur.tagName?.toLowerCase();
1679
+ const r = cur.getAttribute?.("role")?.toLowerCase();
1680
+ const cl = (cur.className ?? "").toLowerCase();
1681
+ if (t === "header" || r === "banner") {
1682
+ landmark = "Header";
1683
+ break;
1684
+ }
1685
+ if (t === "footer" || r === "contentinfo") {
1686
+ landmark = "Footer";
1687
+ break;
1688
+ }
1689
+ if (t === "aside" || r === "complementary" || cl.includes("sidebar") || cl.includes("sidenav") || cl.includes("side-nav")) {
1690
+ landmark = "Sidebar";
1691
+ break;
1692
+ }
1693
+ if (t === "nav" || r === "navigation") {
1694
+ landmark = cur.getAttribute("aria-label")?.trim() || "Site Navigation";
1695
+ break;
1696
+ }
1697
+ cur = cur.parentElement;
1698
+ }
1699
+ const key = `nav|${label.toLowerCase()}|${href}`;
1700
+ if (!seen.has(key)) {
1701
+ seen.add(key);
1702
+ items.push({ meaning: label, role: "nav-link", notes: JSON.stringify({ href: href.slice(0, 150), landmark }) });
1703
+ }
1704
+ });
1705
+ let fc = 0;
1706
+ document.querySelectorAll('footer a, [role="contentinfo"] a, [class*="footer" i] a').forEach((a) => {
1707
+ if (fc >= 20 || !isV(a)) return;
1708
+ const label = (a.getAttribute("aria-label") || a.textContent?.replace(/\s+/g, " ").trim() || "").slice(0, 60);
1709
+ const href = a.href ?? "";
1710
+ if (!label || !href) return;
1711
+ const key = `footer|${label.toLowerCase()}|${href}`;
1712
+ if (!seen.has(key)) {
1713
+ seen.add(key);
1714
+ items.push({ meaning: label, role: "footer-link", notes: JSON.stringify({ href: href.slice(0, 150) }) });
1715
+ fc++;
1716
+ }
1717
+ });
1718
+ {
1719
+ const navHrefsSeen = /* @__PURE__ */ new Set();
1720
+ for (const it of items) {
1721
+ try {
1722
+ const h = JSON.parse(it.notes ?? "{}").href;
1723
+ if (h) navHrefsSeen.add(h);
1724
+ } catch {
1725
+ }
1726
+ }
1727
+ const gpGroups = /* @__PURE__ */ new Map();
1728
+ document.querySelectorAll("a[href]").forEach((a) => {
1729
+ if (!isV(a)) return;
1730
+ const href = a.href ?? "";
1731
+ if (!href || /^(javascript:|#$|mailto:|tel:)/i.test(href) || navHrefsSeen.has(href)) return;
1732
+ const label = (a.getAttribute("aria-label") || a.textContent?.replace(/\s+/g, " ").trim() || "").slice(0, 60);
1733
+ if (!label || label.length > 40) return;
1734
+ const parent = a.parentElement;
1735
+ const gp = parent?.parentElement;
1736
+ if (!parent || !gp) return;
1737
+ if (parent.querySelectorAll("a").length !== 1) return;
1738
+ const gpTag = gp.tagName.toLowerCase();
1739
+ if (["body", "html", "form"].includes(gpTag)) return;
1740
+ const arr = gpGroups.get(gp) ?? [];
1741
+ arr.push({ label, href });
1742
+ gpGroups.set(gp, arr);
1743
+ });
1744
+ for (const [, links] of gpGroups.entries()) {
1745
+ if (links.length >= 4) {
1746
+ for (const { label, href } of links) {
1747
+ const k = `cluster|${label.toLowerCase()}|${href}`;
1748
+ if (!seen.has(k)) {
1749
+ seen.add(k);
1750
+ items.push({ meaning: label, role: "nav-link", notes: JSON.stringify({ href: href.slice(0, 150), landmark: "Sidebar" }) });
1751
+ }
1752
+ }
1753
+ }
1754
+ }
1755
+ }
1756
+ return items;
1757
+ }).catch(() => []);
1758
+ const allElements = [
1759
+ ...elements,
1760
+ ..._navEls.map((ne) => ({ ...ne, expectedData: void 0, boundingRect: void 0, ariaState: void 0, parentLandmark: void 0 }))
1761
+ ];
1762
+ if (allElements.length > 0) {
1763
+ await AppBrain.clearScreenElements(opts.tenantId, opts.projectId, screen.id).catch(() => {
1764
+ });
1765
+ for (let _ei = 0; _ei < allElements.length; _ei += 4) {
1766
+ const _batch = allElements.slice(_ei, _ei + 4);
1767
+ await Promise.all(_batch.map(
1768
+ (el) => AppBrain.saveElement(opts.tenantId, opts.projectId, screen.id, el.meaning, el.role, el.expectedData, el.notes, void 0, true, el.boundingRect, el.ariaState, el.parentLandmark).catch(() => {
1769
+ })
1770
+ ));
1771
+ }
1772
+ logger.info({ url, screenId: screen.id, elements: elements.length, navLinks: _navEls.length }, "[lite-worker] screen captured");
1773
+ } else if (!revisitQueue.includes(url)) {
1774
+ logger.warn({ url, screenId: screen.id }, "[lite-worker] 0 elements \u2014 queued for P3a revisit");
1775
+ revisitQueue.push(url);
1776
+ }
1777
+ if (screen?.id) {
1778
+ const [shadowRes, a11yRes, swRes] = await Promise.race([
1779
+ Promise.allSettled([
1780
+ crawlShadowDom(workerPage),
1781
+ crawlAccessibilityTree(workerPage),
1782
+ inspectServiceWorker(workerPage)
1783
+ ]),
1784
+ new Promise(
1785
+ (resolve) => setTimeout(() => resolve([
1786
+ { status: "rejected", reason: "timeout" },
1787
+ { status: "rejected", reason: "timeout" },
1788
+ { status: "rejected", reason: "timeout" }
1789
+ ]), 15e3)
1790
+ )
1791
+ ]);
1792
+ const litePayload = { screenId: screen.id, url };
1793
+ if (shadowRes.status === "fulfilled" && shadowRes.value && shadowRes.value.shadowHostCount > 0)
1794
+ litePayload.shadowDom = shadowRes.value;
1795
+ if (a11yRes.status === "fulfilled" && a11yRes.value)
1796
+ litePayload.accessibilityTree = a11yRes.value;
1797
+ if (swRes.status === "fulfilled" && swRes.value.supported)
1798
+ litePayload.serviceWorker = swRes.value;
1799
+ if (Object.keys(litePayload).length > 2) {
1800
+ AppBrain.saveCrawlEvidence(opts.tenantId, opts.projectId, {
1801
+ jobId: opts.jobId,
1802
+ type: "PER_SCREEN_PROBES",
1803
+ payload: litePayload
1804
+ }).catch(() => {
1805
+ });
1806
+ }
1807
+ }
1332
1808
  const nextLinks = await workerPage.evaluate((originStr) => {
1333
- return Array.from(document.querySelectorAll("a[href]")).map((a) => {
1809
+ return Array.from(document.querySelectorAll("a[href],[data-href]")).map((a) => {
1810
+ const raw = a.getAttribute("href") || a.getAttribute("data-href") || "";
1334
1811
  try {
1335
- return new URL(a.getAttribute("href"), window.location.href).href;
1812
+ return new URL(raw, window.location.href).href;
1336
1813
  } catch {
1337
1814
  return "";
1338
1815
  }
1339
- }).filter((h) => h && new URL(h).origin === originStr && !h.includes("#")).slice(0, 200);
1816
+ }).filter((h) => {
1817
+ if (!h) return false;
1818
+ try {
1819
+ const u = new URL(h);
1820
+ if (u.origin !== originStr) return false;
1821
+ if (u.hash && !u.hash.startsWith("#/") && !u.hash.startsWith("#!/")) return false;
1822
+ return true;
1823
+ } catch {
1824
+ return false;
1825
+ }
1826
+ }).slice(0, 200);
1340
1827
  }, origin).catch(() => []);
1341
1828
  for (const link of nextLinks) {
1829
+ if (!isUsefulNavigationUrl(link, origin)) continue;
1342
1830
  const cLink = canonicalize(link);
1343
- if (!visited.has(cLink)) {
1831
+ if (!visited.has(cLink) && !queued.has(cLink)) {
1832
+ queued.add(cLink);
1344
1833
  queue.push(link);
1345
1834
  edges.push({ fromUrl: url, toUrl: link });
1346
1835
  }
1347
1836
  }
1348
1837
  discovered.push({ url, screenId: screen.id, elements: elements.length, elementFacts: elements, screenshotPath: shotPath, version: screen.version });
1838
+ const lwEffMax = isFinite(maxScreens) ? Math.min(visited.size + queue.length, maxScreens * 3) : visited.size + queue.length;
1839
+ maxSeenTotal = Math.max(maxSeenTotal, lwEffMax);
1840
+ const lwRawPct = 20 + Math.round(Math.min(57, visited.size / Math.max(1, maxSeenTotal) * 57));
1841
+ const lwPct = Math.max(lastEmittedPct, lwRawPct);
1842
+ lastEmittedPct = lwPct;
1843
+ await onProgress?.(lwPct, `Crawling screens (${visited.size} mapped, ${queue.length} pending)`).catch(() => {
1844
+ });
1349
1845
  } catch (err) {
1350
1846
  logger.warn({ url, err: err?.message ?? String(err) }, "[lite-worker] URL processing failed");
1351
1847
  discovered.push({ url, error: err?.message ?? String(err) });
@@ -1353,7 +1849,7 @@ async function crawlProject(opts, onProgress) {
1353
1849
  }
1354
1850
  }
1355
1851
  const liteWorkerPromises = [];
1356
- if (CRAWL_PAGE_PARALLELISM > 1 && playwrightBrowser) {
1852
+ if (CRAWL_PAGE_PARALLELISM > 1 && playwrightBrowser && !opts.discoveryMode) {
1357
1853
  const pwCtx = playwrightBrowser.contexts()[0];
1358
1854
  for (let i = 1; i < CRAWL_PAGE_PARALLELISM; i++) {
1359
1855
  const wp = await pwCtx.newPage();
@@ -1361,7 +1857,7 @@ async function crawlProject(opts, onProgress) {
1361
1857
  Object.defineProperty(navigator, "webdriver", { get: () => void 0 });
1362
1858
  }).catch(() => {
1363
1859
  });
1364
- wp.setDefaultTimeout(45e3);
1860
+ wp.setDefaultTimeout(tuning.defaultTimeoutMs || 45e3);
1365
1861
  liteWorkerPromises.push(runLiteWorker(wp).finally(() => wp.close().catch(() => {
1366
1862
  })));
1367
1863
  }
@@ -1386,9 +1882,10 @@ async function crawlProject(opts, onProgress) {
1386
1882
  }
1387
1883
  const url = queue.shift();
1388
1884
  const canonUrl = canonicalize(url);
1389
- logger.info({ url, visited: visited.size, queued: queue.length, segment: `${visited.size + 1}/${segmentSize}` }, "[crawler] visiting URL");
1390
1885
  const apiObservationStartIndex = capturedApiEndpoints.length;
1391
1886
  if (visited.has(canonUrl)) continue;
1887
+ visited.set(canonUrl, "_pending_");
1888
+ logger.info({ url, visited: visited.size, queued: queue.length, segment: `${visited.size}/${segmentSize}` }, "[crawler] visiting URL");
1392
1889
  if (robotsCache && !await robotsCache.isAllowed(url)) {
1393
1890
  logger.info({ url }, "[crawler] robots.txt: skipping disallowed URL");
1394
1891
  continue;
@@ -1403,6 +1900,11 @@ async function crawlProject(opts, onProgress) {
1403
1900
  console.log(`[crawler] skip generated dynamic route: ${url}`);
1404
1901
  continue;
1405
1902
  }
1903
+ if (API_PATH_RE.test(pathname) || EXPORT_PATH_RE.test(pathname) || DOWNLOAD_EXT_RE.test(pathname)) {
1904
+ console.log(`[crawler] skip API/export/download path: ${url}`);
1905
+ visited.set(canonUrl, "_skipped_api_");
1906
+ continue;
1907
+ }
1406
1908
  } catch {
1407
1909
  }
1408
1910
  try {
@@ -1481,7 +1983,7 @@ async function crawlProject(opts, onProgress) {
1481
1983
  logger.info({ url }, "[crawler] temporarily cleared session for public auth route");
1482
1984
  }
1483
1985
  }
1484
- const navErr15 = await page.goto(url, { waitUntil: "domcontentloaded", timeout: 15e3 }).then(() => null, (e) => e);
1986
+ const navErr15 = await page.goto(url, { waitUntil: "domcontentloaded", timeout: tuning.navTimeoutMs }).then(() => null, (e) => e);
1485
1987
  if (navErr15) {
1486
1988
  if (/timeout/i.test(String(navErr15?.message ?? navErr15))) {
1487
1989
  await page.goto(url, { waitUntil: "domcontentloaded", timeout: 3e4 }).catch((navErr) => {
@@ -1493,14 +1995,19 @@ async function crawlProject(opts, onProgress) {
1493
1995
  }
1494
1996
  rawPage.off("response", onRedirectResponse);
1495
1997
  if (redirectHops.length > 0) {
1998
+ const isCircularRedirect = new Set(redirectHops.map((h) => h.from)).size < redirectHops.length;
1496
1999
  redirectRegistry.register({
1497
2000
  originalUrl: url,
1498
2001
  finalUrl: page.url(),
1499
2002
  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,
2003
+ isCircular: isCircularRedirect,
1501
2004
  hopCount: redirectHops.length,
1502
2005
  totalDurationMs: Date.now() - navStart
1503
2006
  });
2007
+ if (isCircularRedirect) {
2008
+ logger.warn({ url, hops: redirectHops.map((h) => h.from) }, "[crawler] circular redirect detected \u2014 skipping URL");
2009
+ continue;
2010
+ }
1504
2011
  }
1505
2012
  const navMs = Date.now() - navStart;
1506
2013
  navCount++;
@@ -1510,9 +2017,36 @@ async function crawlProject(opts, onProgress) {
1510
2017
  console.warn(`[crawler] connection failed for ${url} \u2014 skipping (chrome-error page)`);
1511
2018
  continue;
1512
2019
  }
1513
- await page.waitForLoadState("load", { timeout: 8e3 }).catch(() => {
2020
+ await (rawPage ?? page).waitForLoadState("load", { timeout: 8e3 }).catch(() => {
1514
2021
  });
1515
- await waitForStableDOM(page);
2022
+ await waitForStableDOM(page, 8e3, 400, rawPage ?? void 0);
2023
+ const sparseCheck = await (rawPage ?? page).evaluate(
2024
+ () => document.querySelectorAll('button,a,input,select,textarea,[role="button"],[aria-label]').length
2025
+ ).catch(() => 99);
2026
+ if (sparseCheck < 15) {
2027
+ await (rawPage ?? page).waitForLoadState("networkidle", { timeout: 6e3 }).catch(() => {
2028
+ });
2029
+ await waitForStableDOM(page, 4e3, 300, rawPage ?? void 0);
2030
+ }
2031
+ try {
2032
+ const spinnerPage = rawPage ?? page;
2033
+ if (typeof spinnerPage.waitForFunction === "function") {
2034
+ await spinnerPage.waitForFunction(() => {
2035
+ const body = document.body;
2036
+ if (!body) return true;
2037
+ const text = body.innerText?.toLowerCase() ?? "";
2038
+ 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;
2039
+ const busyEl = body.querySelector('[aria-busy="true"]');
2040
+ if (busyEl && busyEl.offsetParent !== null) return false;
2041
+ if (body.querySelector('[data-loading="true"],[data-pending="true"],[data-fetching="true"]')) return false;
2042
+ if (body.querySelector("progress:not([value])")) return false;
2043
+ const skeleton = body.querySelector(".skeleton,.skeleton-loader,.shimmer,.pulse-loader,.content-loader");
2044
+ if (skeleton && skeleton.offsetParent !== null) return false;
2045
+ return true;
2046
+ }, { timeout: 12e3 });
2047
+ }
2048
+ } catch {
2049
+ }
1516
2050
  if (!opts.discoveryMode) {
1517
2051
  try {
1518
2052
  const appModalVisible = await page.evaluate(() => {
@@ -1529,7 +2063,7 @@ async function crawlProject(opts, onProgress) {
1529
2063
  const modalShotPath = path.join(opts.screenshotDir ?? "/tmp", `modal-${modalHash}.png`);
1530
2064
  await page.screenshot({ path: modalShotPath, fullPage: false }).catch(() => {
1531
2065
  });
1532
- const modalName = await page.evaluate(() => {
2066
+ const modalName = await (rawPage ?? page).evaluate(() => {
1533
2067
  const h = document.querySelector('[role="dialog"] h1,[role="dialog"] h2,[role="dialog"] [role="heading"]');
1534
2068
  return h?.textContent?.trim() ?? "Modal";
1535
2069
  }).catch(() => "Modal");
@@ -1537,13 +2071,18 @@ async function crawlProject(opts, onProgress) {
1537
2071
  if (modalScreen) {
1538
2072
  await AppBrain.saveScreenshot(opts.tenantId, opts.projectId, modalScreen.id, modalShotPath, modalScreen.version, "DESKTOP").catch(() => {
1539
2073
  });
1540
- logger.info({ url, name: modalName }, "[crawler] captured app modal before dismiss");
2074
+ const modalElements = await domFallbackElements(page, rawPage ?? void 0).catch(() => []);
2075
+ for (const el of modalElements) {
2076
+ 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(() => {
2077
+ });
2078
+ }
2079
+ logger.info({ url, name: modalName, elementCount: modalElements.length }, "[crawler] captured app modal before dismiss");
1541
2080
  }
1542
2081
  }
1543
2082
  } catch {
1544
2083
  }
1545
2084
  await dismissOverlays(page);
1546
- await infiniteScrollToBottom(page, { maxScrolls: 30, scrollDelay: 350, stabilizeMs: 500 }).catch(() => {
2085
+ await infiniteScrollToBottom(page, { maxScrolls: 30, scrollDelay: 350, stabilizeMs: 500 }, rawPage ?? void 0).catch(() => {
1547
2086
  });
1548
2087
  }
1549
2088
  if (cdpSession?.off) cdpSession.off("Network.responseReceived", secHeadersCdp);
@@ -1563,6 +2102,22 @@ async function crawlProject(opts, onProgress) {
1563
2102
  const weWantedThisPage = BFS_ERROR_PATH.test(new URL(url).pathname) || BFS_LOGIN_PATH.test(new URL(url).pathname);
1564
2103
  const redirectedToError = !weWantedThisPage && (BFS_ERROR_PATH.test(new URL(landedUrl).pathname) || BFS_LOGIN_PATH.test(new URL(landedUrl).pathname));
1565
2104
  if (redirectedToError) {
2105
+ if (BFS_LOGIN_PATH.test(new URL(landedUrl).pathname)) {
2106
+ loginWallRedirectCount++;
2107
+ if (!opts.credentials && loginWallRedirectCount >= LOGIN_WALL_ABORT_THRESHOLD) {
2108
+ AppBrain.saveCrawlEvidence(opts.tenantId, opts.projectId, {
2109
+ jobId: opts.jobId,
2110
+ type: "AUTH_FAILURE",
2111
+ payload: {
2112
+ url,
2113
+ landed: landedUrl,
2114
+ reason: `Login wall detected: ${loginWallRedirectCount} consecutive URLs redirected to login. Add credentials in project settings to crawl authenticated pages.`
2115
+ }
2116
+ }).catch(() => {
2117
+ });
2118
+ throw new Error(`[AUTH_FAILURE] Login wall detected: ${loginWallRedirectCount} URLs redirected to the login page. Add credentials in project settings and retry the crawl.`);
2119
+ }
2120
+ }
1566
2121
  logger.warn({ requested: url, landed: landedUrl }, "[crawler] Mid-crawl redirect to auth/error \u2014 attempting re-auth");
1567
2122
  let reauthed = false;
1568
2123
  if (opts.credentials) {
@@ -1579,9 +2134,9 @@ async function crawlProject(opts, onProgress) {
1579
2134
  logger.info({ url }, "[crawler] Re-auth succeeded \u2014 retrying URL");
1580
2135
  await page.goto(url, { waitUntil: "domcontentloaded" }).catch(() => {
1581
2136
  });
1582
- await page.waitForLoadState("networkidle", { timeout: 8e3 }).catch(() => {
2137
+ await (rawPage ?? page).waitForLoadState("networkidle", { timeout: 8e3 }).catch(() => {
1583
2138
  });
1584
- await waitForStableDOM(page);
2139
+ await waitForStableDOM(page, 8e3, 400, rawPage ?? void 0);
1585
2140
  const afterReauthUrl = page.url();
1586
2141
  if (BFS_ERROR_PATH.test(new URL(afterReauthUrl).pathname) || BFS_LOGIN_PATH.test(new URL(afterReauthUrl).pathname)) {
1587
2142
  logger.warn({ url }, "[crawler] Still redirecting after re-auth \u2014 skipping this URL");
@@ -1598,6 +2153,10 @@ async function crawlProject(opts, onProgress) {
1598
2153
  throw new Error("[AUTH_FAILURE] Re-authentication failed. Session is unrecoverable. Re-capture the session in project settings and retry the crawl.");
1599
2154
  }
1600
2155
  } else {
2156
+ if (opts.discoveryMode) {
2157
+ logger.info({ url, landed: landedUrl }, "[discover] skipping auth-required URL \u2014 no credentials, continuing BFS");
2158
+ continue;
2159
+ }
1601
2160
  logger.warn("[crawler] Session expired mid-crawl, no credentials to recover \u2014 skipping URL");
1602
2161
  AppBrain.saveCrawlEvidence(opts.tenantId, opts.projectId, {
1603
2162
  jobId: opts.jobId,
@@ -1611,6 +2170,7 @@ async function crawlProject(opts, onProgress) {
1611
2170
  if (!redirectedToError && landedUrl !== url) {
1612
2171
  logger.info({ requested: url, landed: landedUrl }, "[crawler] navigated with redirect");
1613
2172
  }
2173
+ if (!redirectedToError) loginWallRedirectCount = 0;
1614
2174
  if (opts.discoveryMode) {
1615
2175
  visited.set(canonicalize(url), url);
1616
2176
  await (rawPage ?? page).waitForLoadState("networkidle", { timeout: 1e4 }).catch(() => {
@@ -1749,7 +2309,7 @@ async function crawlProject(opts, onProgress) {
1749
2309
  continue;
1750
2310
  }
1751
2311
  const preDomHash = await computeDomHash(page);
1752
- if (seenDomHashes.has(preDomHash)) {
2312
+ if (preDomHash !== "" && seenDomHashes.has(preDomHash)) {
1753
2313
  console.log(`[crawler] skip ${url} \u2014 duplicate DOM structure (same content as prior page)`);
1754
2314
  continue;
1755
2315
  }
@@ -1835,7 +2395,7 @@ async function crawlProject(opts, onProgress) {
1835
2395
  const lastSeg = allSegs[allSegs.length - 1];
1836
2396
  pathName = lastSeg.slice(0, 8).toUpperCase();
1837
2397
  }
1838
- const SIG_PARAMS = ["product_cat", "category", "cat", "type", "tag", "brand", "filter", "tab", "section", "view"];
2398
+ const SIG_PARAMS = ["product_cat", "category", "cat", "type", "tag", "brand", "filter", "tab", "section", "view", "play", "mode", "id", "page"];
1839
2399
  const paramSuffix = SIG_PARAMS.map((p) => {
1840
2400
  const v = u.searchParams.get(p);
1841
2401
  return v ? ` \xB7 ${v.replace(/[-_]/g, " ").replace(/\b\w/g, (c) => c.toUpperCase())}` : "";
@@ -1855,14 +2415,15 @@ async function crawlProject(opts, onProgress) {
1855
2415
  }
1856
2416
  const candidate = parts[0].trim();
1857
2417
  const isBrand = candidate.split(/\s+/).length === 1 && (candidate.toLowerCase().includes(brandName) || candidate.toLowerCase() === brandName);
1858
- if (!isBrand && candidate.split(/\s+/).length > 1 && !/^\d[\d\s]*$/.test(candidate)) {
2418
+ const isSiteGenericTitle = homeFingerprintTitle !== "" && candidate === homeFingerprintTitle;
2419
+ if (!isBrand && !isSiteGenericTitle && candidate.split(/\s+/).length > 1 && !/^\d[\d\s]*$/.test(candidate)) {
1859
2420
  return candidate;
1860
2421
  }
1861
2422
  }
1862
2423
  } catch {
1863
2424
  }
1864
2425
  try {
1865
- const activeNav = await page.locator('[aria-current="page"]').first().textContent({ timeout: 2e3 }).catch(() => null);
2426
+ const activeNav = await page.locator('[aria-current="page"]').first().textContent({ timeout: tuning.defaultTimeoutMs }).catch(() => null);
1866
2427
  if (activeNav) {
1867
2428
  const t = activeNav.trim();
1868
2429
  if (t.length > 1 && t.length < 60 && t.toLowerCase() !== brandName) return t;
@@ -1881,7 +2442,7 @@ async function crawlProject(opts, onProgress) {
1881
2442
  ".page-header h2"
1882
2443
  ];
1883
2444
  for (const sel of selectors) {
1884
- const h = await page.locator(sel).first().textContent({ timeout: 2e3 }).catch(() => null);
2445
+ const h = await page.locator(sel).first().textContent({ timeout: tuning.defaultTimeoutMs }).catch(() => null);
1885
2446
  if (!h) continue;
1886
2447
  const t = h.trim();
1887
2448
  if (t.length < 2 || t.length > 80) continue;
@@ -1971,21 +2532,24 @@ async function crawlProject(opts, onProgress) {
1971
2532
  if (cachedLinks) {
1972
2533
  logger.info({ url, cachedCount: cachedLinks.length }, "[crawler] link-discovery: using cached recipe \u2014 AI will still run and merge");
1973
2534
  }
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
- })()}`);
2535
+ {
2536
+ const effMax = isFinite(maxScreens) ? Math.min(visited.size + queue.length, maxScreens * 3) : visited.size + queue.length;
2537
+ maxSeenTotal = Math.max(maxSeenTotal, effMax);
2538
+ const rawPrePct = 20 + Math.round(Math.min(57, visited.size / Math.max(1, maxSeenTotal) * 57));
2539
+ const preCapturePct = Math.max(lastEmittedPct, rawPrePct);
2540
+ lastEmittedPct = preCapturePct;
2541
+ await onProgress?.(preCapturePct, `Crawling ${visited.size} of ~${visited.size + queue.length} \xB7 ${(() => {
2542
+ try {
2543
+ return new URL(url).pathname || "/";
2544
+ } catch {
2545
+ return "/";
2546
+ }
2547
+ })()}`);
2548
+ }
2549
+ await waitForScreenshotReady(page, rawPage ?? void 0);
1985
2550
  const preDomCount = await page.evaluate(
1986
2551
  () => document.querySelectorAll('button,a,input,select,textarea,[role="button"],[onclick],[aria-label]').length
1987
2552
  ).catch(() => 0);
1988
- await waitForScreenshotReady(page);
1989
2553
  await onProgress?.(lastEmittedPct, `Capturing ${(() => {
1990
2554
  try {
1991
2555
  return new URL(url).pathname || "/";
@@ -1993,8 +2557,8 @@ async function crawlProject(opts, onProgress) {
1993
2557
  return "/";
1994
2558
  }
1995
2559
  })()}`);
1996
- const pageDpr = await page.evaluate(() => window.devicePixelRatio ?? 1).catch(() => 1);
1997
- const elements = await extractElements(stagehand, page, elementLLM ?? void 0).catch((err) => {
2560
+ const pageDpr = await (rawPage ?? page).evaluate(() => window.devicePixelRatio ?? 1).catch(() => 1);
2561
+ const elements = await extractElements(stagehand, page, elementLLM ?? void 0, rawPage ?? void 0).catch((err) => {
1998
2562
  console.error("[crawler] extractElements failed:", err);
1999
2563
  return [];
2000
2564
  });
@@ -2045,8 +2609,8 @@ async function crawlProject(opts, onProgress) {
2045
2609
  }).catch((err) => console.error("[crawler] page.screenshot failed:", err)),
2046
2610
  // Always run AI link discovery (even when cache exists) — cache is merged in, not used as a replacement.
2047
2611
  // 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) => {
2612
+ // Note: preDomCount < aiMinElements guard removed — it was backwards: sparse pages (SPAs) need AI most.
2613
+ discoverLinks(stagehand, origin, page, linkLLM ?? void 0, aiSkipCount > 0, rawPage ?? void 0).then((freshLinks) => {
2050
2614
  if (aiSkipCount > 0) aiSkipCount--;
2051
2615
  aiErrorStreak = 0;
2052
2616
  const merged = (cachedLinks ? Array.from(/* @__PURE__ */ new Set([...cachedLinks, ...freshLinks])) : freshLinks).filter((href) => isUsefulNavigationUrl(href, origin));
@@ -2079,7 +2643,7 @@ async function crawlProject(opts, onProgress) {
2079
2643
  }
2080
2644
  return fallback;
2081
2645
  }),
2082
- readScreen(page).catch(() => [])
2646
+ readScreen(rawPage ?? page).catch(() => [])
2083
2647
  ]);
2084
2648
  await page.evaluate(() => {
2085
2649
  const container = document.getElementById("__zeta_element_highlights");
@@ -2100,6 +2664,104 @@ async function crawlProject(opts, onProgress) {
2100
2664
  }
2101
2665
  }).catch(() => {
2102
2666
  });
2667
+ {
2668
+ const safeContext = typeof rawPage?.context === "function" ? rawPage.context() : typeof page?.context === "function" ? page.context() : playwrightBrowser?.contexts()[0] ?? null;
2669
+ const roTimeout = new Promise(
2670
+ (resolve) => setTimeout(() => resolve([
2671
+ { status: "rejected", reason: "probe-timeout" },
2672
+ { status: "rejected", reason: "probe-timeout" },
2673
+ { status: "rejected", reason: "probe-timeout" },
2674
+ { status: "rejected", reason: "probe-timeout" },
2675
+ { status: "rejected", reason: "probe-timeout" }
2676
+ ]), 25e3)
2677
+ );
2678
+ const [nestedCtxRes, shadowRes, a11yRes, perfRes, swRes] = await Promise.race([
2679
+ Promise.allSettled([
2680
+ crawlNestedContexts(rawPage ?? page, safeContext, url),
2681
+ crawlShadowDom(rawPage ?? page),
2682
+ crawlAccessibilityTree(rawPage ?? page),
2683
+ collectPerfMetrics(rawPage ?? page, url),
2684
+ inspectServiceWorker(rawPage ?? page)
2685
+ ]),
2686
+ roTimeout
2687
+ ]);
2688
+ const roPayload = { screenId: screen.id, url };
2689
+ if (nestedCtxRes.status === "fulfilled" && nestedCtxRes.value && nestedCtxRes.value.totalContextsFound > 0)
2690
+ roPayload.nestedContexts = nestedCtxRes.value;
2691
+ if (shadowRes.status === "fulfilled" && shadowRes.value && shadowRes.value.shadowHostCount > 0)
2692
+ roPayload.shadowDom = shadowRes.value;
2693
+ if (a11yRes.status === "fulfilled" && a11yRes.value)
2694
+ roPayload.accessibilityTree = a11yRes.value;
2695
+ if (perfRes.status === "fulfilled" && perfRes.value.requestCount > 0)
2696
+ roPayload.performance = perfRes.value;
2697
+ if (swRes.status === "fulfilled" && swRes.value.supported)
2698
+ roPayload.serviceWorker = swRes.value;
2699
+ if (Object.keys(roPayload).length > 2) {
2700
+ AppBrain.saveCrawlEvidence(opts.tenantId, opts.projectId, {
2701
+ jobId: opts.jobId,
2702
+ type: "PER_SCREEN_PROBES",
2703
+ payload: roPayload
2704
+ }).catch(() => {
2705
+ });
2706
+ }
2707
+ if (screen?.id && opts.interactive !== false && safeContext) {
2708
+ void (async () => {
2709
+ let probePage = null;
2710
+ try {
2711
+ const openProbePages = safeContext.pages().length;
2712
+ if (openProbePages > 8) {
2713
+ logger.warn({ url, openProbePages }, "[crawler] probe page cap reached \u2014 skipping mutating probes for this screen");
2714
+ return;
2715
+ }
2716
+ probePage = await safeContext.newPage();
2717
+ await probePage.goto(url, { waitUntil: "domcontentloaded", timeout: 15e3 }).catch(() => {
2718
+ });
2719
+ await probePage.waitForLoadState("networkidle", { timeout: 8e3 }).catch(() => {
2720
+ });
2721
+ await waitForStableDOM(probePage, 4e3, 400).catch(() => {
2722
+ });
2723
+ const muTimeout = new Promise(
2724
+ (resolve) => setTimeout(() => resolve([
2725
+ { status: "rejected", reason: "probe-timeout" },
2726
+ { status: "rejected", reason: "probe-timeout" },
2727
+ { status: "rejected", reason: "probe-timeout" },
2728
+ { status: "rejected", reason: "probe-timeout" }
2729
+ ]), 6e4)
2730
+ );
2731
+ const [dropEnumRes, crudRes, filterRes, advancedRes] = await Promise.race([
2732
+ Promise.allSettled([
2733
+ enumerateDropdowns(probePage),
2734
+ probeCrudFlows(probePage, name),
2735
+ crawlFiltersAndPagination(probePage),
2736
+ probeAdvancedUI(probePage)
2737
+ ]),
2738
+ muTimeout
2739
+ ]);
2740
+ const muPayload = { screenId: screen.id, url };
2741
+ if (dropEnumRes.status === "fulfilled" && dropEnumRes.value.totalOptionsFound > 0)
2742
+ muPayload.dropdowns = dropEnumRes.value;
2743
+ if (crudRes.status === "fulfilled" && (crudRes.value.flows?.length > 0 || crudRes.value.toastsObserved?.length > 0))
2744
+ muPayload.crudFlows = crudRes.value;
2745
+ if (filterRes.status === "fulfilled" && filterRes.value.filters?.length > 0)
2746
+ muPayload.filtersAndPagination = filterRes.value;
2747
+ 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"))
2748
+ muPayload.advancedUI = advancedRes.value;
2749
+ if (Object.keys(muPayload).length > 2) {
2750
+ AppBrain.saveCrawlEvidence(opts.tenantId, opts.projectId, {
2751
+ jobId: opts.jobId,
2752
+ type: "PER_SCREEN_PROBES_MUTATING",
2753
+ payload: muPayload
2754
+ }).catch(() => {
2755
+ });
2756
+ }
2757
+ } catch {
2758
+ } finally {
2759
+ await probePage?.close().catch(() => {
2760
+ });
2761
+ }
2762
+ })();
2763
+ }
2764
+ }
2103
2765
  try {
2104
2766
  const domContent = await capturePageHtml(page);
2105
2767
  if (!domContent) {
@@ -2248,252 +2910,315 @@ ${snippet}`,
2248
2910
  try {
2249
2911
  const baseline = await AppBrain.getVisualBaselineForDiff(opts.tenantId, screen.id, "DESKTOP", gitInfo.branch);
2250
2912
  if (baseline && baseline.storagePath !== shotPath) {
2251
- const { screenshotDiffPercent, gridLayoutDiff } = await import("./phash.js");
2252
- const [{ pHashDistance, changeLevel }, layoutChanges, elementShifts] = await Promise.all([
2253
- screenshotDiffPercent(baseline.storagePath, shotPath, baseline.ignoredRegions),
2254
- gridLayoutDiff(baseline.storagePath, shotPath, baseline.ignoredRegions),
2255
- AppBrain.computeElementLayoutDiff(opts.tenantId, screen.id, "DESKTOP")
2256
- ]);
2257
- let verdict = pHashVerdict(pHashDistance, changeLevel, [
2258
- ...layoutChanges,
2259
- ...elementShifts.map((s) => ({ location: "element", severity: s.severity, description: s.description }))
2260
- ]);
2261
- if (verdict.changeType === "SIGNIFICANT" || verdict.changeType === "CRITICAL") {
2262
- try {
2263
- const regions = baseline.ignoredRegions ?? [];
2264
- const [baselineBuffer, newBuffer] = regions.length > 0 ? await (async () => {
2265
- const { applyMask } = await import("./phash.js");
2266
- return Promise.all([applyMask(baseline.storagePath, regions), applyMask(shotPath, regions)]);
2267
- })() : await Promise.all([
2268
- fs.readFile(baseline.storagePath),
2269
- fs.readFile(shotPath)
2270
- ]);
2271
- const aiResult = await aiVisualCompare({
2272
- baselineImageBase64: baselineBuffer.toString("base64"),
2273
- newImageBase64: newBuffer.toString("base64"),
2274
- screenName: screen.name,
2275
- screenPurpose: "Application screen captured during automated crawl",
2276
- tenantId: opts.tenantId,
2277
- projectId: opts.projectId
2278
- });
2279
- verdict = mergeAiVisualVerdict(verdict, aiResult, screen.name);
2280
- } catch (aiErr) {
2281
- logger.warn({ screenId: screen.id, err: String(aiErr) }, "[crawler] AI visual compare failed, keeping pHash-only verdict");
2913
+ let baselinePath = baseline.storagePath;
2914
+ let tempBaselinePath = null;
2915
+ const localExists = await fs.access(baseline.storagePath).then(() => true, () => false);
2916
+ if (!localExists) {
2917
+ if (baseline.cloudUrl) {
2918
+ try {
2919
+ const resp = await fetch(baseline.cloudUrl);
2920
+ if (resp.ok) {
2921
+ const buf = Buffer.from(await resp.arrayBuffer());
2922
+ tempBaselinePath = `${shotPath}.baseline-tmp.png`;
2923
+ await fs.writeFile(tempBaselinePath, buf);
2924
+ baselinePath = tempBaselinePath;
2925
+ logger.info({ screenId: screen.id }, "[crawler] VisualDiff baseline downloaded from cloud storage");
2926
+ } else {
2927
+ logger.warn({ screenId: screen.id, status: resp.status }, "[crawler] VisualDiff skipped \u2014 baseline cloud fetch returned non-200");
2928
+ return;
2929
+ }
2930
+ } catch (dlErr) {
2931
+ logger.warn({ screenId: screen.id, err: String(dlErr) }, "[crawler] VisualDiff skipped \u2014 baseline cloud download failed");
2932
+ return;
2933
+ }
2934
+ } else {
2935
+ logger.warn({ screenId: screen.id }, "[crawler] VisualDiff skipped \u2014 baseline not on disk and no cloudUrl available");
2936
+ return;
2282
2937
  }
2283
2938
  }
2284
- let { changeType, recommendation, changes: allChanges, bugReportDraft, aiSummary } = verdict;
2285
- if (changeType !== "NONE") {
2286
- try {
2287
- const project = await AppBrain.getProject(opts.tenantId, opts.projectId);
2288
- const threshold = project?.visualDiffThreshold ?? null;
2289
- if (threshold !== null && pHashDistance < threshold) {
2290
- changeType = "NONE";
2939
+ try {
2940
+ const { screenshotDiffPercent, gridLayoutDiff } = await import("./phash.js");
2941
+ const [{ pHashDistance, changeLevel }, layoutChanges, elementShifts] = await Promise.all([
2942
+ screenshotDiffPercent(baselinePath, shotPath, baseline.ignoredRegions),
2943
+ gridLayoutDiff(baselinePath, shotPath, baseline.ignoredRegions),
2944
+ AppBrain.computeElementLayoutDiff(opts.tenantId, screen.id, "DESKTOP")
2945
+ ]);
2946
+ let verdict = pHashVerdict(pHashDistance, changeLevel, [
2947
+ ...layoutChanges,
2948
+ ...elementShifts.map((s) => ({ location: "element", severity: s.severity, description: s.description }))
2949
+ ]);
2950
+ if (verdict.changeType === "SIGNIFICANT" || verdict.changeType === "CRITICAL") {
2951
+ try {
2952
+ const regions = baseline.ignoredRegions ?? [];
2953
+ const [baselineBuffer, newBuffer] = regions.length > 0 ? await (async () => {
2954
+ const { applyMask } = await import("./phash.js");
2955
+ return Promise.all([applyMask(baselinePath, regions), applyMask(shotPath, regions)]);
2956
+ })() : await Promise.all([
2957
+ fs.readFile(baselinePath),
2958
+ fs.readFile(shotPath)
2959
+ ]);
2960
+ const aiResult = await aiVisualCompare({
2961
+ baselineImageBase64: baselineBuffer.toString("base64"),
2962
+ newImageBase64: newBuffer.toString("base64"),
2963
+ screenName: screen.name,
2964
+ screenPurpose: "Application screen captured during automated crawl",
2965
+ tenantId: opts.tenantId,
2966
+ projectId: opts.projectId
2967
+ });
2968
+ verdict = mergeAiVisualVerdict(verdict, aiResult, screen.name);
2969
+ } catch (aiErr) {
2970
+ logger.warn({ screenId: screen.id, err: String(aiErr) }, "[crawler] AI visual compare failed, keeping pHash-only verdict");
2291
2971
  }
2292
- } catch {
2293
2972
  }
2294
- }
2295
- if (changeType !== "NONE") {
2296
- let newScreenshotCloudUrl;
2297
- let diffImageCloudUrl;
2298
- try {
2299
- const { getTenantStorageConfig, uploadScreenshotToTenantStorage, buildArtifactKey } = await import("@detiq/app-brain");
2300
- const storageCfg = await getTenantStorageConfig(opts.tenantId).catch(() => null);
2301
- if (storageCfg) {
2302
- const key = buildArtifactKey(opts.tenantId, opts.projectId, "visual-diffs", `${screen.id}-v${screen.version}.png`, storageCfg.prefix);
2303
- newScreenshotCloudUrl = await uploadScreenshotToTenantStorage(storageCfg, shotPath, key);
2304
- try {
2305
- const { generatePixelmatchDiff } = await import("./phash.js");
2306
- const diffBuffer = await generatePixelmatchDiff(baseline.storagePath, shotPath, baseline.ignoredRegions ?? []);
2307
- const diffTmpPath = `${shotPath}.diff.png`;
2308
- await fs.writeFile(diffTmpPath, diffBuffer);
2309
- const diffKey = buildArtifactKey(opts.tenantId, opts.projectId, "visual-diffs", `${screen.id}-v${screen.version}-diff.png`, storageCfg.prefix);
2310
- diffImageCloudUrl = await uploadScreenshotToTenantStorage(storageCfg, diffTmpPath, diffKey);
2311
- fs.unlink(diffTmpPath).catch(() => {
2312
- });
2313
- } catch (diffErr) {
2314
- logger.warn({ screenId: screen.id, err: String(diffErr) }, "[crawler] pixelmatch diff generation failed, skipping");
2973
+ let { changeType, recommendation, changes: allChanges, bugReportDraft, aiSummary } = verdict;
2974
+ if (changeType !== "NONE") {
2975
+ try {
2976
+ const project = await AppBrain.getProject(opts.tenantId, opts.projectId);
2977
+ const threshold = project?.visualDiffThreshold ?? null;
2978
+ if (threshold !== null && pHashDistance < threshold) {
2979
+ changeType = "NONE";
2315
2980
  }
2981
+ } catch {
2316
2982
  }
2317
- } catch {
2318
2983
  }
2319
- await AppBrain.saveVisualDiff(opts.tenantId, {
2320
- baselineId: baseline.id,
2321
- newScreenshotPath: shotPath,
2322
- newScreenshotCloudUrl,
2323
- diffImageCloudUrl,
2324
- diffScore: pHashDistance,
2325
- changeType,
2326
- changes: allChanges,
2327
- recommendation,
2328
- bugReportDraft,
2329
- aiSummary
2984
+ if (changeType !== "NONE") {
2985
+ let newScreenshotCloudUrl;
2986
+ let diffImageCloudUrl;
2987
+ try {
2988
+ const { getTenantStorageConfig, uploadScreenshotToTenantStorage, buildArtifactKey } = await import("@detiq/app-brain");
2989
+ const storageCfg = await getTenantStorageConfig(opts.tenantId).catch(() => null);
2990
+ if (storageCfg) {
2991
+ const key = buildArtifactKey(opts.tenantId, opts.projectId, "visual-diffs", `${screen.id}-v${screen.version}.png`, storageCfg.prefix);
2992
+ newScreenshotCloudUrl = await uploadScreenshotToTenantStorage(storageCfg, shotPath, key);
2993
+ try {
2994
+ const { generatePixelmatchDiff } = await import("./phash.js");
2995
+ const diffBuffer = await generatePixelmatchDiff(baselinePath, shotPath, baseline.ignoredRegions ?? []);
2996
+ const diffTmpPath = `${shotPath}.diff.png`;
2997
+ await fs.writeFile(diffTmpPath, diffBuffer);
2998
+ const diffKey = buildArtifactKey(opts.tenantId, opts.projectId, "visual-diffs", `${screen.id}-v${screen.version}-diff.png`, storageCfg.prefix);
2999
+ diffImageCloudUrl = await uploadScreenshotToTenantStorage(storageCfg, diffTmpPath, diffKey);
3000
+ fs.unlink(diffTmpPath).catch(() => {
3001
+ });
3002
+ } catch (diffErr) {
3003
+ logger.warn({ screenId: screen.id, err: String(diffErr) }, "[crawler] pixelmatch diff generation failed, skipping");
3004
+ }
3005
+ }
3006
+ } catch {
3007
+ }
3008
+ await AppBrain.saveVisualDiff(opts.tenantId, {
3009
+ baselineId: baseline.id,
3010
+ newScreenshotPath: shotPath,
3011
+ newScreenshotCloudUrl,
3012
+ diffImageCloudUrl,
3013
+ diffScore: pHashDistance,
3014
+ changeType,
3015
+ changes: allChanges,
3016
+ recommendation,
3017
+ bugReportDraft,
3018
+ aiSummary
3019
+ });
3020
+ }
3021
+ } finally {
3022
+ if (tempBaselinePath) fs.unlink(tempBaselinePath).catch(() => {
2330
3023
  });
2331
3024
  }
2332
3025
  }
2333
3026
  } catch (err) {
2334
- const missing = String(err?.message ?? err).includes("Input file is missing");
2335
- if (missing) {
2336
- logger.warn({ screenId: screen.id, version: screen.version, err: String(err?.message ?? err) }, "[crawler] VisualDiff skipped \u2014 baseline screenshot not on disk (stored in cloud only). Run a full recrawl to regenerate baselines.");
2337
- } else {
2338
- console.warn("[crawler] VisualDiff pipeline failed:", err);
2339
- }
3027
+ console.warn("[crawler] VisualDiff pipeline failed:", err);
2340
3028
  }
2341
3029
  })();
2342
3030
  }
2343
3031
  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;
3032
+ const postExtractUrl = (() => {
3033
+ try {
3034
+ return page.url();
3035
+ } catch {
3036
+ return url;
3037
+ }
3038
+ })();
3039
+ const postExtractParsed = (() => {
3040
+ try {
3041
+ return new URL(postExtractUrl);
3042
+ } catch {
3043
+ return new URL(url);
2372
3044
  }
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
3045
+ })();
3046
+ const driftedToAuthWall = !weWantedThisPage && (BFS_LOGIN_PATH.test(postExtractParsed.pathname) || BFS_ERROR_PATH.test(postExtractParsed.pathname));
3047
+ if (driftedToAuthWall) {
3048
+ logger.warn({ url, postExtractUrl }, "[crawler] Page drifted to auth wall during element extraction \u2014 skipping element save to prevent login-page element contamination");
3049
+ AppBrain.saveCrawlEvidence(opts.tenantId, opts.projectId, {
3050
+ screenId: screen.id,
3051
+ jobId: opts.jobId,
3052
+ type: "AUTH_FAILURE",
3053
+ payload: { url, landed: postExtractUrl, reason: "Session expired during element extraction. Re-capture session and re-crawl this page." }
2380
3054
  }).catch(() => {
2381
3055
  });
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 {
3056
+ } else {
3057
+ if (elements.length === 0) {
3058
+ logger.warn({ url, screenId: screen.id }, "[crawler] extractElements returned 0 \u2014 preserving existing elements to avoid data loss");
3059
+ if (!revisitQueue.includes(url)) {
3060
+ revisitQueue.push(url);
3061
+ logger.info({ url }, "[crawler] 0-element page queued for re-visit with extended hydration wait");
3062
+ }
3063
+ } else {
3064
+ await AppBrain.clearScreenElements(opts.tenantId, opts.projectId, screen.id).catch(() => {
3065
+ });
3066
+ for (let _ei = 0; _ei < elements.length; _ei += 4) {
3067
+ const _batch = elements.slice(_ei, _ei + 4);
3068
+ await Promise.all(_batch.map(async (el) => {
3069
+ const saved = await AppBrain.saveElement(opts.tenantId, opts.projectId, screen.id, el.meaning, el.role, el.expectedData, el.notes, void 0, true, el.boundingRect, el.ariaState, el.parentLandmark);
3070
+ const label = el.meaning;
3071
+ const role = el.role ?? "unknown";
3072
+ const selectorHint = atByRole.get(role) ?? role;
3073
+ const selectors = [
3074
+ { type: "role-label", value: `${role}:${label}`, confidence: 0.9 },
3075
+ { type: "aria-label", value: label, confidence: 0.85 },
3076
+ { type: "tag-hint", value: selectorHint, confidence: 0.6 }
3077
+ ];
3078
+ AppBrain.saveElementFingerprint(opts.tenantId, opts.projectId, saved.id, selectors).catch(console.warn);
3079
+ }));
2394
3080
  }
2395
3081
  }
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 {
3082
+ for (const lm of linkMappings) {
3083
+ const safety = assessActionSafety(lm.label, domainProfile);
3084
+ if (!safety.allowed) {
3085
+ AppBrain.saveCrawlEvidence(opts.tenantId, opts.projectId, {
3086
+ screenId: screen.id,
3087
+ jobId: opts.jobId,
3088
+ type: "ACTION_BLOCKED_BY_SAFETY_POLICY",
3089
+ payload: { label: lm.label, href: lm.href, decision: safety, profile: domainProfile.industry }
3090
+ }).catch(() => {
3091
+ });
3092
+ continue;
2405
3093
  }
2406
- const canonHref = canonicalize(href);
2407
- if (!queued.has(canonHref) && !visited.has(canonHref) && queued.size < maxScreens * 2) {
3094
+ pendingTransitions.push({ fromScreenId: screen.id, elementMeaning: lm.label, targetUrl: lm.href });
3095
+ AppBrain.saveActionRecipe(opts.tenantId, opts.projectId, {
3096
+ domain: new URL(url).hostname,
3097
+ purpose: `navigate:${lm.label}`,
3098
+ fingerprint: sha256(`${canonicalize(url)}:${lm.label}:${canonicalize(lm.href)}`),
3099
+ steps: [{ action: "click", label: lm.label, targetUrl: lm.href, fromUrl: url }],
3100
+ successRate: 0.5
3101
+ }).catch(() => {
3102
+ });
3103
+ if (!selective) {
2408
3104
  try {
2409
- const hrefPath = new URL(href).pathname;
2410
- if (isGeneratedDynamicPath(hrefPath)) continue;
3105
+ const targetPath = new URL(lm.href).pathname;
3106
+ const canonLmHref = canonicalize(lm.href);
3107
+ if (!SKIP_PATH.test(targetPath) && isUsefulNavigationUrl(lm.href, origin) && !queued.has(canonLmHref) && !visited.has(canonLmHref) && queued.size < maxScreens * 2) {
3108
+ if (robotsCache && !await robotsCache.isAllowed(lm.href)) continue;
3109
+ queued.add(canonLmHref);
3110
+ queue.push(lm.href, { parentElements: elements.length });
3111
+ edges.push({ fromUrl: url, toUrl: lm.href });
3112
+ logger.info({ fromUrl: url, newUrl: lm.href, label: lm.label, queueSize: queue.length }, "[crawler] anchor mapping added to queue");
3113
+ }
2411
3114
  } catch {
2412
3115
  }
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
3116
  }
2418
3117
  }
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) {
3118
+ if (!selective) {
3119
+ for (const href of nextLinks) {
3120
+ edges.push({ fromUrl: url, toUrl: href });
2427
3121
  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
- }
3122
+ const hrefParams = new URL(href).searchParams;
3123
+ const actionVal = hrefParams.get("action") ?? "";
3124
+ if (/^yith-|^wc-|^vc_|^elementor/.test(actionVal)) continue;
2435
3125
  } catch {
2436
3126
  }
3127
+ const canonHref = canonicalize(href);
3128
+ if (!queued.has(canonHref) && !visited.has(canonHref) && queued.size < maxScreens * 2) {
3129
+ try {
3130
+ const hrefPath = new URL(href).pathname;
3131
+ if (isGeneratedDynamicPath(hrefPath)) continue;
3132
+ } catch {
3133
+ }
3134
+ if (robotsCache && !await robotsCache.isAllowed(href)) continue;
3135
+ queued.add(canonHref);
3136
+ queue.push(href, { parentElements: elements.length });
3137
+ logger.info({ newUrl: href, queueSize: queue.length }, "[crawler] new URL added to queue");
3138
+ }
2437
3139
  }
2438
- } catch {
2439
- }
2440
- if (preDomCount < 25 && !revisitQueue.includes(url)) {
3140
+ logger.info({ fromUrl: url, discovered: nextLinks.length, queueAfter: queue.length, visitedSoFar: visited.size }, "[crawler] link processing done");
2441
3141
  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");
3142
+ const jsRoutes = await page.evaluate(() => {
3143
+ const routes = window.__zetaDiscoveredRoutes ?? [];
3144
+ window.__zetaDiscoveredRoutes = [];
3145
+ return routes;
3146
+ }).catch(() => []);
3147
+ for (const route of jsRoutes) {
3148
+ try {
3149
+ const abs = route.startsWith("http") ? route : `${origin}${route.startsWith("/") ? "" : "/"}${route}`;
3150
+ const canonRoute = canonicalize(abs);
3151
+ if (!queued.has(canonRoute) && !visited.has(canonRoute)) {
3152
+ queued.add(canonRoute);
3153
+ queue.push(abs);
3154
+ logger.info({ route: abs }, "[crawler] history.pushState: discovered SPA route");
3155
+ }
3156
+ } catch {
3157
+ }
2448
3158
  }
2449
3159
  } catch {
2450
3160
  }
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");
3161
+ const asyncContentCheckMinDom = parseInt(process.env.CRAWLER_ASYNC_CONTENT_MIN_DOM ?? "") || 25;
3162
+ if (preDomCount < asyncContentCheckMinDom && !revisitQueue.includes(url)) {
3163
+ try {
3164
+ const postDomCount = await page.evaluate(
3165
+ () => document.querySelectorAll('button,a,input,select,textarea,[role="button"],[onclick],[aria-label]').length
3166
+ ).catch(() => 0);
3167
+ const asyncGrowthRatio = parseFloat(process.env.CRAWLER_ASYNC_GROWTH_RATIO ?? "") || 1.3;
3168
+ const asyncGrowthFloor = parseInt(process.env.CRAWLER_ASYNC_GROWTH_FLOOR ?? "") || 4;
3169
+ if (postDomCount > preDomCount * asyncGrowthRatio + asyncGrowthFloor) {
3170
+ revisitQueue.push(url);
3171
+ logger.info({ url, preDomCount, postDomCount }, "[P3a] Async content detected \u2014 scheduled for re-visit");
2461
3172
  }
3173
+ } catch {
2462
3174
  }
2463
- } catch {
2464
3175
  }
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");
3176
+ if (visited.size <= (parseInt(process.env.CRAWLER_SPA_ROUTER_DETECT_LIMIT ?? "") || 5)) {
3177
+ try {
3178
+ const jsDetectedRoutes = await detectJsRoutes(page, origin);
3179
+ for (const route of jsDetectedRoutes) {
3180
+ const canonRoute = canonicalize(route);
3181
+ if (isUsefulNavigationUrl(route, origin) && !queued.has(canonRoute) && !visited.has(canonRoute) && queued.size < maxScreens * 2) {
3182
+ queued.add(canonRoute);
3183
+ queue.push(route, { parentElements: elements.length });
3184
+ logger.info({ route }, "[P2] JS router: discovered route added to queue");
3185
+ }
3186
+ }
3187
+ } catch {
2475
3188
  }
2476
- } catch {
2477
3189
  }
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 });
3190
+ for (const relPath of [...xhrDiscoveredPaths]) {
3191
+ xhrDiscoveredPaths.delete(relPath);
3192
+ try {
3193
+ const abs = `${origin}${relPath}`;
3194
+ const canon = canonicalize(abs);
3195
+ if (isUsefulNavigationUrl(abs, origin) && !queued.has(canon) && !visited.has(canon) && queued.size < maxScreens * 2) {
3196
+ queued.add(canon);
3197
+ queue.push(abs);
3198
+ logger.info({ url: abs }, "[P4] XHR: page URL from JSON response added to queue");
2494
3199
  }
3200
+ } catch {
3201
+ }
3202
+ }
3203
+ if (nextLinks.length === 0 && elements.length < (parseInt(process.env.CRAWL_SKYVERN_PAGE_MIN_ELEMENTS ?? "") || 5) && opts.skyvernConfig) {
3204
+ try {
3205
+ logger.info({ url }, "Gap 5: thin page detected \u2014 asking Skyvern to discover navigation URLs");
3206
+ const skyvernNav = await skyvernNavigate(
3207
+ url,
3208
+ ["Find and return all navigation links, menu items, and page URLs accessible from this screen"],
3209
+ opts.skyvernConfig
3210
+ );
3211
+ for (const skyvernUrl of skyvernNav.visitedUrls) {
3212
+ const canonSkyvernUrl = canonicalize(skyvernUrl);
3213
+ if (!queued.has(canonSkyvernUrl) && !visited.has(canonSkyvernUrl) && queued.size < maxScreens * 2) {
3214
+ logger.info({ url: skyvernUrl }, "Gap 5: Skyvern-discovered URL added to ZeTa crawl queue");
3215
+ queued.add(canonSkyvernUrl);
3216
+ queue.push(skyvernUrl);
3217
+ pendingTransitions.push({ fromScreenId: screen.id, elementMeaning: "Skyvern-discovered link", targetUrl: skyvernUrl });
3218
+ }
3219
+ }
3220
+ } catch {
2495
3221
  }
2496
- } catch {
2497
3222
  }
2498
3223
  }
2499
3224
  }
@@ -2569,7 +3294,8 @@ ${snippet}`,
2569
3294
  };
2570
3295
  }
2571
3296
  const successfulScreenCount = discovered.filter((d) => d.screenId).length;
2572
- if (successfulScreenCount < 10 && opts.skyvernConfig) {
3297
+ const skyvernMinScreens = parseInt(process.env.CRAWL_SKYVERN_MIN_SCREENS ?? "") || 10;
3298
+ if (successfulScreenCount < skyvernMinScreens && opts.skyvernConfig) {
2573
3299
  await onProgress?.(78, "Few screens found \u2014 using Skyvern to discover additional navigation URLs");
2574
3300
  const currentUrl = page.url();
2575
3301
  const skyvernNav = await skyvernNavigate(
@@ -2587,16 +3313,16 @@ ${snippet}`,
2587
3313
  if (visited.size >= maxScreens) break;
2588
3314
  try {
2589
3315
  await page.goto(skyvernUrl, { waitUntil: "domcontentloaded", timeout: 2e4 });
2590
- await page.waitForTimeout(1500);
3316
+ await new Promise((r) => setTimeout(r, 1500));
2591
3317
  const skyvernName = new URL(skyvernUrl).pathname.split("/").filter(Boolean).pop() ?? "Screen";
2592
3318
  const screen = await AppBrain.saveScreen(opts.tenantId, opts.projectId, skyvernName, skyvernUrl, opts.jobId, "SKYVERN_AI");
2593
3319
  const shotPath = `${opts.screenshotDir ?? "/tmp"}/${screen.id}-v${screen.version}.png`;
2594
- await waitForScreenshotReady(page);
3320
+ await waitForScreenshotReady(page, rawPage ?? void 0);
2595
3321
  await page.screenshot({ path: shotPath, fullPage: true });
2596
3322
  await AppBrain.saveScreenshot(opts.tenantId, opts.projectId, screen.id, shotPath, screen.version, "DESKTOP");
2597
3323
  const [elems, links, linkMappings] = await Promise.all([
2598
- extractElements(stagehand, page, elementLLM ?? void 0),
2599
- discoverLinks(stagehand, origin, page, linkLLM ?? void 0, aiSkipCount > 0),
3324
+ extractElements(stagehand, page, elementLLM ?? void 0, rawPage ?? void 0),
3325
+ discoverLinks(stagehand, origin, page, linkLLM ?? void 0, aiSkipCount > 0, rawPage ?? void 0),
2600
3326
  extractLinkMappings(page, origin)
2601
3327
  ]);
2602
3328
  for (const el of elems) {
@@ -2626,15 +3352,15 @@ ${snippet}`,
2626
3352
  if (!isUsefulNavigationUrl(href, origin)) continue;
2627
3353
  try {
2628
3354
  await page.goto(href, { waitUntil: "domcontentloaded", timeout: 2e4 });
2629
- await page.waitForTimeout(1e3);
3355
+ await new Promise((r) => setTimeout(r, 1e3));
2630
3356
  const nm = new URL(href).pathname.split("/").filter(Boolean).pop() ?? "Screen";
2631
3357
  const sc = await AppBrain.saveScreen(opts.tenantId, opts.projectId, nm, href, opts.jobId, "SKYVERN_AI");
2632
3358
  const sp = `${opts.screenshotDir ?? "/tmp"}/${sc.id}-v${sc.version}.png`;
2633
- await waitForScreenshotReady(page);
3359
+ await waitForScreenshotReady(page, rawPage ?? void 0);
2634
3360
  await page.screenshot({ path: sp, fullPage: true });
2635
3361
  await AppBrain.saveScreenshot(opts.tenantId, opts.projectId, sc.id, sp, sc.version, "DESKTOP");
2636
3362
  const [elems2, linkMappings2] = await Promise.all([
2637
- extractElements(stagehand, page, elementLLM ?? void 0),
3363
+ extractElements(stagehand, page, elementLLM ?? void 0, rawPage ?? void 0),
2638
3364
  extractLinkMappings(page, origin)
2639
3365
  ]);
2640
3366
  for (const el of elems2) {
@@ -2680,28 +3406,51 @@ ${snippet}`,
2680
3406
  );
2681
3407
  }
2682
3408
  } catch (err) {
2683
- logger.warn({ err: String(err) }, "[P1] URL pattern completion failed \u2014 non-fatal, continuing");
3409
+ const msg = String(err);
3410
+ logger.warn({ err: msg }, "[P1] URL pattern completion failed \u2014 non-fatal, continuing");
3411
+ 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
3412
  }
2685
3413
  if (revisitQueue.length > 0) {
2686
3414
  await onProgress?.(79, `Re-visiting ${revisitQueue.length} page(s) with async content\u2026`);
2687
3415
  logger.info({ count: revisitQueue.length }, "[P3a] Starting async content re-visit pass");
2688
3416
  for (const revisitUrl of revisitQueue.slice(0, 10)) {
2689
- const existingScreenId = visited.get(canonicalize(revisitUrl));
2690
- if (!existingScreenId) continue;
3417
+ const rawScreenId = visited.get(canonicalize(revisitUrl));
3418
+ const isAuthWallRevisit = rawScreenId === "_auth_wall_";
3419
+ if (!rawScreenId) continue;
2691
3420
  try {
2692
3421
  await page.goto(revisitUrl, { waitUntil: "networkidle", timeout: 25e3 });
2693
- await page.waitForTimeout(2e3);
3422
+ await new Promise((r) => setTimeout(r, 2e3));
3423
+ await waitForScreenshotReady(page, rawPage ?? void 0);
3424
+ const landedUrl = (rawPage ?? page).url?.() ?? revisitUrl;
3425
+ const landedCanon = canonicalize(landedUrl);
3426
+ let existingScreenId = isAuthWallRevisit ? visited.get(landedCanon) ?? null : rawScreenId;
3427
+ if (isAuthWallRevisit && (!existingScreenId || existingScreenId.startsWith("_")) && !visited.has(landedCanon)) {
3428
+ const pathParts = new URL(landedUrl).pathname.split("/").filter(Boolean);
3429
+ const screenName = pathParts.map((p) => p.charAt(0).toUpperCase() + p.slice(1).replace(/[-_]/g, " ")).join(" \u2014 ") || "Home";
3430
+ const newScreen = await AppBrain.saveScreen(opts.tenantId, opts.projectId, screenName, landedUrl, opts.jobId, "LINK_FOLLOW").catch(() => null);
3431
+ if (newScreen) {
3432
+ visited.set(landedCanon, newScreen.id);
3433
+ existingScreenId = newScreen.id;
3434
+ }
3435
+ }
3436
+ if (!existingScreenId || existingScreenId.startsWith("_")) {
3437
+ logger.warn({ url: revisitUrl, landed: landedUrl }, "[P3a] Re-visit skipped \u2014 could not resolve screen ID");
3438
+ continue;
3439
+ }
2694
3440
  const shotPath2 = `${opts.screenshotDir ?? "/tmp"}/${existingScreenId}-revisit.png`;
2695
- await waitForScreenshotReady(page);
2696
3441
  await page.screenshot({ path: shotPath2, fullPage: true });
2697
- await AppBrain.saveScreenshot(opts.tenantId, opts.projectId, existingScreenId, shotPath2, 1, "DESKTOP").catch(() => {
3442
+ await AppBrain.saveScreenshot(opts.tenantId, opts.projectId, existingScreenId, shotPath2, 1, "DESKTOP", void 0).catch(() => {
2698
3443
  });
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(() => {
3444
+ const revisitElements = await extractElements(stagehand, page, elementLLM ?? void 0, rawPage ?? void 0).catch(() => []);
3445
+ if (revisitElements.length > 0) {
3446
+ await AppBrain.clearScreenElements(opts.tenantId, opts.projectId, existingScreenId).catch(() => {
2702
3447
  });
3448
+ for (const el of revisitElements) {
3449
+ 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(() => {
3450
+ });
3451
+ }
2703
3452
  }
2704
- const revisitLinks = await discoverLinks(stagehand, origin, page, linkLLM ?? void 0, false).catch(() => []);
3453
+ const revisitLinks = await discoverLinks(stagehand, origin, page, linkLLM ?? void 0, false, rawPage ?? void 0).catch(() => []);
2705
3454
  for (const href of revisitLinks) {
2706
3455
  const canon = canonicalize(href);
2707
3456
  if (isUsefulNavigationUrl(href, origin) && !queued.has(canon) && !visited.has(canon) && queued.size < maxScreens * 2) {
@@ -2709,13 +3458,68 @@ ${snippet}`,
2709
3458
  queue.push(href);
2710
3459
  }
2711
3460
  }
2712
- logger.info({ url: revisitUrl, elements: revisitElements.length, newLinks: revisitLinks.length }, "[P3a] Re-visit complete");
3461
+ logger.info({ url: revisitUrl, landed: landedUrl, elements: revisitElements.length, newLinks: revisitLinks.length }, "[P3a] Re-visit complete");
2713
3462
  } catch (err) {
2714
3463
  logger.warn({ url: revisitUrl, err: String(err) }, "[P3a] Re-visit failed \u2014 non-fatal");
2715
3464
  }
2716
3465
  }
2717
3466
  }
2718
- if (visited.size >= 3) {
3467
+ if (queue.length > 0 && visited.size < maxScreens) {
3468
+ const p3bCount = Math.min(queue.length, maxScreens - visited.size);
3469
+ logger.info({ newLinks: p3bCount }, "[P3b] Extended BFS \u2014 processing links discovered during revisit pass");
3470
+ await onProgress?.(82, `Extended crawl: ${p3bCount} newly discovered page(s)\u2026`);
3471
+ while (queue.length > 0 && visited.size < maxScreens && visited.size < segmentSize) {
3472
+ const href = queue.shift();
3473
+ if (!href) break;
3474
+ const canonHref = canonicalize(href);
3475
+ if (visited.has(canonHref)) continue;
3476
+ if (!isUsefulNavigationUrl(href, origin)) continue;
3477
+ visited.set(canonHref, "_pending_");
3478
+ try {
3479
+ await page.goto(href, { waitUntil: "networkidle", timeout: 25e3 }).catch(() => {
3480
+ });
3481
+ await waitForScreenshotReady(page, rawPage ?? void 0);
3482
+ const pathParts = new URL(href).pathname.split("/").filter(Boolean);
3483
+ const nm = pathParts.map((p) => p.charAt(0).toUpperCase() + p.slice(1).replace(/[-_]/g, " ")).join(" \u2014 ") || "Home";
3484
+ const screen = await AppBrain.saveScreen(opts.tenantId, opts.projectId, nm, href, opts.jobId, "LINK_FOLLOW");
3485
+ visited.set(canonHref, screen.id);
3486
+ const sp = `${opts.screenshotDir ?? "/tmp"}/${screen.id}-v${screen.version}.png`;
3487
+ await page.screenshot({ path: sp, fullPage: true }).catch(() => {
3488
+ });
3489
+ const cloudUrl3 = await uploadToCloud(sp, `${opts.tenantId}/${opts.projectId}/screenshots/${path.basename(sp)}`).catch(() => "");
3490
+ await AppBrain.saveScreenshot(opts.tenantId, opts.projectId, screen.id, sp, screen.version, "DESKTOP", cloudUrl3).catch(() => {
3491
+ });
3492
+ const [p3bElems, p3bLinks] = await Promise.all([
3493
+ extractElements(stagehand, page, elementLLM ?? void 0, rawPage ?? void 0).catch(() => []),
3494
+ discoverLinks(stagehand, origin, page, linkLLM ?? void 0, aiSkipCount > 0, rawPage ?? void 0).catch(() => [])
3495
+ ]);
3496
+ if (p3bElems.length > 0) {
3497
+ await AppBrain.clearScreenElements(opts.tenantId, opts.projectId, screen.id).catch(() => {
3498
+ });
3499
+ for (let _ei3 = 0; _ei3 < p3bElems.length; _ei3 += 4) {
3500
+ const _b3 = p3bElems.slice(_ei3, _ei3 + 4);
3501
+ await Promise.all(_b3.map(
3502
+ (el) => AppBrain.saveElement(opts.tenantId, opts.projectId, screen.id, el.meaning, el.role, el.expectedData, el.notes, void 0, true, el.boundingRect, el.ariaState, el.parentLandmark).catch(() => {
3503
+ })
3504
+ ));
3505
+ }
3506
+ }
3507
+ for (const link of p3bLinks) {
3508
+ const c = canonicalize(link);
3509
+ if (isUsefulNavigationUrl(link, origin) && !queued.has(c) && !visited.has(c) && queued.size < maxScreens * 2) {
3510
+ queued.add(c);
3511
+ queue.push(link);
3512
+ }
3513
+ }
3514
+ discovered.push({ url: href, screenId: screen.id, elements: p3bElems.length, version: screen.version });
3515
+ logger.info({ url: href, elements: p3bElems.length, newLinks: p3bLinks.length }, "[P3b] Extended BFS screen captured");
3516
+ } catch (err) {
3517
+ logger.warn({ url: href, err: String(err) }, "[P3b] Extended BFS URL failed");
3518
+ visited.set(canonHref, "_error_");
3519
+ }
3520
+ }
3521
+ }
3522
+ if (visited.size >= (parseInt(process.env.CRAWLER_GAP_PASS_MIN_SCREENS ?? "") || 3)) {
2719
3523
  try {
2720
3524
  await onProgress?.(80, "Running gap analysis\u2026");
2721
3525
  const llmCfgForGap = await getCachedCrawlLLM(opts.tenantId);
@@ -2739,7 +3543,7 @@ ${snippet}`,
2739
3543
  });
2740
3544
  logger.info({ confirmed: confirmedGaps.length, total: gapCandidates.length }, "[P6] Gap pass: visiting confirmed gap URLs");
2741
3545
  const gapLoginPattern = /\/(login|signin|sign-in|auth|log-in)(\/|$|\?)/i;
2742
- for (const gap of confirmedGaps.slice(0, 8)) {
3546
+ for (const gap of confirmedGaps.slice(0, parseInt(process.env.CRAWLER_GAP_PASS_MAX_VISITS ?? "") || 8)) {
2743
3547
  const gapUrl = gap.confirmedUrl;
2744
3548
  if (visited.size >= maxScreens) break;
2745
3549
  try {
@@ -2748,16 +3552,16 @@ ${snippet}`,
2748
3552
  logger.warn({ gapUrl, landed: page.url() }, "[P6] Gap URL redirected to login \u2014 skipping");
2749
3553
  continue;
2750
3554
  }
2751
- await page.waitForTimeout(1500);
3555
+ await new Promise((r) => setTimeout(r, 1500));
2752
3556
  const gapName = gap.category;
2753
3557
  const gapScreen = await AppBrain.saveScreen(opts.tenantId, opts.projectId, gapName, gapUrl, opts.jobId, "ENTRY_POINT");
2754
3558
  const gapShot = `${opts.screenshotDir ?? "/tmp"}/${gapScreen.id}-v${gapScreen.version}.png`;
2755
- await waitForScreenshotReady(page);
3559
+ await waitForScreenshotReady(page, rawPage ?? void 0);
2756
3560
  await page.screenshot({ path: gapShot, fullPage: true });
2757
3561
  await AppBrain.saveScreenshot(opts.tenantId, opts.projectId, gapScreen.id, gapShot, gapScreen.version, "DESKTOP");
2758
3562
  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(() => []),
3563
+ extractElements(stagehand, page, elementLLM ?? void 0, rawPage ?? void 0).catch(() => []),
3564
+ discoverLinks(stagehand, origin, page, linkLLM ?? void 0, false, rawPage ?? void 0).catch(() => []),
2761
3565
  extractLinkMappings(page, origin).catch(() => [])
2762
3566
  ]);
2763
3567
  for (const el of gapElems) {
@@ -2785,11 +3589,14 @@ ${snippet}`,
2785
3589
  logger.info({ total: gapCandidates.length }, "[P6] Gap pass: no confirmed gaps found");
2786
3590
  }
2787
3591
  } catch (err) {
2788
- logger.warn({ err: String(err) }, "[P6] Gap pass failed \u2014 non-fatal, continuing");
3592
+ const msg = String(err);
3593
+ logger.warn({ err: msg }, "[P6] Gap pass failed \u2014 non-fatal, continuing");
3594
+ 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
3595
  }
2790
3596
  }
2791
3597
  if (!selective && visited.size > 0) {
2792
- const discoveredUrls = Array.from(visited.keys());
3598
+ const VISIT_MARKER_RE = /^_|^pre-auth$|^skipped$/;
3599
+ const discoveredUrls = Array.from(visited.entries()).filter(([, v]) => typeof v === "string" && !VISIT_MARKER_RE.test(v)).map(([k]) => k);
2793
3600
  await AppBrain.markRemovedScreens(opts.tenantId, opts.projectId, discoveredUrls, opts.jobId);
2794
3601
  }
2795
3602
  await onProgress?.(82, "Saving app map");
@@ -2964,6 +3771,10 @@ ${snippet}`,
2964
3771
  ignoreHTTPSErrors: true,
2965
3772
  ...mainStorageState ? { storageState: mainStorageState } : {}
2966
3773
  });
3774
+ mobileCtx.on("download", (dl) => {
3775
+ dl.cancel().catch(() => {
3776
+ });
3777
+ });
2967
3778
  const mobilePage = await mobileCtx.newPage();
2968
3779
  for (const disc of discovered) {
2969
3780
  if (!disc.screenId) continue;
@@ -2976,11 +3787,13 @@ ${snippet}`,
2976
3787
  await mobilePage.addStyleTag({ content: "*, *::before, *::after { animation-duration: 0s !important; transition-duration: 0s !important; }" });
2977
3788
  } catch {
2978
3789
  }
3790
+ await infiniteScrollToBottom(mobilePage, { maxScrolls: 20, scrollDelay: 300, stabilizeMs: 400 }, mobilePage).catch(() => {
3791
+ });
2979
3792
  const shotPath = path.join(opts.screenshotDir ?? "/tmp", `${disc.screenId}-${profile.toLowerCase()}-v${disc.version}.png`);
2980
- await waitForScreenshotReady(mobilePage);
3793
+ await waitForScreenshotReady(mobilePage, mobilePage);
2981
3794
  const [, vpElements] = await Promise.all([
2982
3795
  mobilePage.screenshot({ path: shotPath, fullPage: true }),
2983
- domFallbackElements(mobilePage)
3796
+ domFallbackElements(mobilePage, mobilePage)
2984
3797
  ]);
2985
3798
  const cloudUrl = await uploadToCloud(shotPath, `${opts.tenantId}/${opts.projectId}/screenshots/${path.basename(shotPath)}`);
2986
3799
  await AppBrain.saveScreenshot(opts.tenantId, opts.projectId, disc.screenId, shotPath, disc.version, profile, cloudUrl);
@@ -3033,6 +3846,22 @@ ${snippet}`,
3033
3846
  const probeInputs = discovered.filter((d) => d.screenId).map((d) => ({ url: d.url, screenId: d.screenId, elements: d.elementFacts ?? [] }));
3034
3847
  await probeAllScreens(page, opts.tenantId, opts.projectId, probeInputs, onProgress);
3035
3848
  }
3849
+ try {
3850
+ await onProgress?.(90, "Generating state graph coverage report");
3851
+ const stateGraph = new ScreenTransitionGraph();
3852
+ const coveragePayload = {
3853
+ stateGraph: stateGraph.getCoverageReport(),
3854
+ totalScreensCrawled: discovered.filter((d) => !!d.screenId).length
3855
+ };
3856
+ AppBrain.saveCrawlEvidence(opts.tenantId, opts.projectId, {
3857
+ jobId: opts.jobId,
3858
+ type: "CRAWL_COVERAGE_REPORT",
3859
+ payload: coveragePayload
3860
+ }).catch(() => {
3861
+ });
3862
+ } catch (extErr) {
3863
+ logger.warn({ err: String(extErr) }, "[coverage-report] non-fatal error");
3864
+ }
3036
3865
  await onProgress?.(97, "Running post-crawl analysis");
3037
3866
  const successScreens = discovered.filter((d) => !!d.screenId);
3038
3867
  ;
@@ -3113,6 +3942,14 @@ ${snippet}`,
3113
3942
  const hasMoreSegments = pendingUrls.length > 0;
3114
3943
  const screensSucceeded = discovered.filter((d) => !!d.screenId).length;
3115
3944
  const screensErrored = discovered.filter((d) => !!d.error && !d.screenId).length;
3945
+ if (screensSucceeded >= 3) {
3946
+ const screensWithElements = discovered.filter((d) => (d.elements ?? 0) > 0 && d.screenId).length;
3947
+ if (screensWithElements === 0) {
3948
+ crawlWarnings.push(
3949
+ `element-extraction: 0 elements found across all ${screensSucceeded} screens \u2014 DOM evaluate may be failing (check server logs for "page.evaluate threw" warnings)`
3950
+ );
3951
+ }
3952
+ }
3116
3953
  return {
3117
3954
  screensFound: screensSucceeded,
3118
3955
  // successful captures only (not errors)
@@ -3129,8 +3966,9 @@ ${snippet}`,
3129
3966
  segmentIndex: segIdx,
3130
3967
  // which segment this was
3131
3968
  hasMoreSegments,
3132
- domHashes: Array.from(seenDomHashes)
3969
+ domHashes: Array.from(seenDomHashes),
3133
3970
  // carry forward to next segment to avoid re-screenshotting
3971
+ crawlWarnings: crawlWarnings.length > 0 ? crawlWarnings : void 0
3134
3972
  };
3135
3973
  } finally {
3136
3974
  if (stagehandPromptTokens > 0 || stagehandCompletionTokens > 0) {
@@ -3165,8 +4003,8 @@ ${snippet}`,
3165
4003
  }
3166
4004
  }
3167
4005
  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) => {
4006
+ async function extractElements(stagehand, page, llmCfg, rawPage) {
4007
+ const domElements = await domFallbackElements(page, rawPage).catch((err) => {
3170
4008
  console.warn("[crawler] domFallbackElements failed (page context issue or DOM error):", err?.message ?? err);
3171
4009
  return [];
3172
4010
  });
@@ -3204,7 +4042,10 @@ async function extractElements(stagehand, page, llmCfg) {
3204
4042
  dynamicElements = [];
3205
4043
  }
3206
4044
  }
3207
- const contentElements = await contentFallbackElements(page);
4045
+ const contentElements = await contentFallbackElements(page).catch((err) => {
4046
+ console.warn("[crawler] contentFallbackElements failed (page context issue):", err?.message ?? err);
4047
+ return [];
4048
+ });
3208
4049
  const crossSourceDedup = (arr) => {
3209
4050
  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
4051
  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 +4086,9 @@ async function extractElements(stagehand, page, llmCfg) {
3245
4086
  return domElements;
3246
4087
  }
3247
4088
  }
3248
- async function discoverLinks(stagehand, origin, page, llmCfg, skipAi = false) {
4089
+ async function discoverLinks(stagehand, origin, page, llmCfg, skipAi = false, rawPage) {
3249
4090
  const navExpanded = await expandNavMenus(page);
3250
- const domLinks = await domFallbackLinks(page, origin);
4091
+ const domLinks = await domFallbackLinks(page, origin, rawPage);
3251
4092
  const interactiveLinks = await interactAndCollectLinks(page, origin);
3252
4093
  if (navExpanded) {
3253
4094
  page.keyboard?.press("Escape").catch(() => {
@@ -3294,8 +4135,8 @@ async function discoverLinks(stagehand, origin, page, llmCfg, skipAi = false) {
3294
4135
  return allDomLinks;
3295
4136
  }
3296
4137
  }
3297
- async function domFallbackElements(page) {
3298
- return page.evaluate(() => {
4138
+ async function domFallbackElements(page, rawPage) {
4139
+ function _browserEval() {
3299
4140
  const out = [];
3300
4141
  const seen = /* @__PURE__ */ new Set();
3301
4142
  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 +4152,7 @@ async function domFallbackElements(page) {
3311
4152
  }
3312
4153
  return results;
3313
4154
  };
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"]';
4155
+ 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
4156
  querySelectorAllDeep(document, selector).forEach((el) => {
3316
4157
  const style = window.getComputedStyle(el);
3317
4158
  if (style.display === "none" || style.visibility === "hidden" || style.opacity === "0" || style.pointerEvents === "none") return;
@@ -3323,11 +4164,10 @@ async function domFallbackElements(page) {
3323
4164
  const tag = el.tagName.toLowerCase();
3324
4165
  const ariaRole = el.getAttribute("role");
3325
4166
  const isActionEl = tag === "button" || tag === "a" || ariaRole === "button";
3326
- if (tag === "a" || ariaRole === "button" && tag !== "button") {
4167
+ if (tag === "a") {
3327
4168
  let p = el.parentElement;
3328
4169
  while (p) {
3329
- const ptag = p.tagName?.toLowerCase();
3330
- if (["header", "footer"].includes(ptag)) return;
4170
+ if (p.tagName?.toLowerCase() === "footer") return;
3331
4171
  p = p.parentElement;
3332
4172
  }
3333
4173
  }
@@ -3472,7 +4312,48 @@ async function domFallbackElements(page) {
3472
4312
  const priority = (e) => ["email", "password", "text", "tel", "date", "number", "select", "textarea", "url", "file"].includes(e.role) ? 0 : 1;
3473
4313
  out.sort((a, b) => priority(a) - priority(b));
3474
4314
  return out;
3475
- }).then((elems) => elems.map((e) => ({ ...e, isInteractive: true }))).catch(() => []);
4315
+ }
4316
+ const _browserEvalStr = `(function(){const __name=(fn,n)=>{try{Object.defineProperty(fn,'name',{value:n,configurable:true})}catch(e){}return fn};return(${_browserEval.toString()})()})();`;
4317
+ if (rawPage && typeof rawPage.context === "function") {
4318
+ try {
4319
+ const ctx = rawPage.context();
4320
+ if (typeof ctx?.newCDPSession === "function") {
4321
+ const cdp = await ctx.newCDPSession(rawPage);
4322
+ try {
4323
+ const evalResult = await cdp.send("Runtime.evaluate", {
4324
+ expression: _browserEvalStr,
4325
+ returnByValue: true,
4326
+ awaitPromise: false
4327
+ });
4328
+ if (evalResult?.exceptionDetails) {
4329
+ console.warn("[crawler] domFallbackElements CDP primary error:", evalResult.exceptionDetails?.exception?.description ?? evalResult.exceptionDetails?.text);
4330
+ } else if (Array.isArray(evalResult?.result?.value)) {
4331
+ return evalResult.result.value.map((e) => ({ ...e, isInteractive: true }));
4332
+ }
4333
+ } finally {
4334
+ cdp.detach().catch(() => {
4335
+ });
4336
+ }
4337
+ }
4338
+ } catch (cdpPrimaryErr) {
4339
+ console.warn("[crawler] domFallbackElements CDP primary failed, trying page.evaluate:", cdpPrimaryErr?.message ?? String(cdpPrimaryErr));
4340
+ }
4341
+ }
4342
+ const evalPage = rawPage ?? page;
4343
+ const _doEvaluate = () => evalPage.evaluate(_browserEvalStr);
4344
+ let result;
4345
+ try {
4346
+ result = await _doEvaluate();
4347
+ } catch (err1) {
4348
+ await new Promise((r) => setTimeout(r, 500));
4349
+ try {
4350
+ result = await _doEvaluate();
4351
+ } catch (err2) {
4352
+ console.warn("[crawler] domFallbackElements page.evaluate threw (attempt 2):", err2?.message ?? String(err2));
4353
+ return [];
4354
+ }
4355
+ }
4356
+ return (result ?? []).map((e) => ({ ...e, isInteractive: true }));
3476
4357
  }
3477
4358
  async function contentFallbackElements(page) {
3478
4359
  return page.evaluate(() => {
@@ -3567,7 +4448,26 @@ async function contentFallbackElements(page) {
3567
4448
  const s = window.getComputedStyle(el);
3568
4449
  return s.display !== "none" && s.visibility !== "hidden" && s.opacity !== "0";
3569
4450
  };
3570
- document.querySelectorAll('nav a, [role="navigation"] a, header a, header nav a, footer nav a, [aria-label*="navigation" i] a, [aria-label*="menu" i] a').forEach((a) => {
4451
+ const navSel = [
4452
+ "nav a",
4453
+ '[role="navigation"] a',
4454
+ "header a",
4455
+ "header nav a",
4456
+ "footer nav a",
4457
+ '[aria-label*="navigation" i] a',
4458
+ '[aria-label*="menu" i] a',
4459
+ '[aria-label*="sidebar" i] a',
4460
+ "aside a",
4461
+ '[role="complementary"] a',
4462
+ '[class*="sidebar" i] a',
4463
+ '[class*="side-nav" i] a',
4464
+ '[class*="sidenav" i] a',
4465
+ '[class*="nav-menu" i] a',
4466
+ '[class*="navmenu" i] a',
4467
+ "[data-nav] a"
4468
+ ].join(", ");
4469
+ const navLinksFoundCount = { n: 0 };
4470
+ document.querySelectorAll(navSel).forEach((a) => {
3571
4471
  if (!isNavVisible(a)) return;
3572
4472
  const label = (a.getAttribute("aria-label") || a.textContent?.replace(/\s+/g, " ").trim() || "").slice(0, 60);
3573
4473
  const href = a.href;
@@ -3577,6 +4477,7 @@ async function contentFallbackElements(page) {
3577
4477
  while (el) {
3578
4478
  const tag = el.tagName?.toLowerCase();
3579
4479
  const role = el.getAttribute?.("role")?.toLowerCase();
4480
+ const cl = (el.className ?? "").toLowerCase();
3580
4481
  if (tag === "header" || role === "banner") {
3581
4482
  landmark = "Header";
3582
4483
  break;
@@ -3585,6 +4486,10 @@ async function contentFallbackElements(page) {
3585
4486
  landmark = "Footer";
3586
4487
  break;
3587
4488
  }
4489
+ if (tag === "aside" || role === "complementary" || cl.includes("sidebar") || cl.includes("sidenav") || cl.includes("side-nav")) {
4490
+ landmark = "Sidebar";
4491
+ break;
4492
+ }
3588
4493
  if (tag === "nav" || role === "navigation") {
3589
4494
  const directLabel = el.getAttribute("aria-label")?.trim() ?? "";
3590
4495
  const labelId = el.getAttribute("aria-labelledby");
@@ -3595,7 +4500,44 @@ async function contentFallbackElements(page) {
3595
4500
  el = el.parentElement;
3596
4501
  }
3597
4502
  addUniq({ meaning: label, role: "nav-link", notes: JSON.stringify({ href: href.slice(0, 150), landmark }) });
4503
+ navLinksFoundCount.n++;
3598
4504
  });
4505
+ {
4506
+ const navHrefsAlready = /* @__PURE__ */ new Set();
4507
+ for (const item of out) {
4508
+ if (item.role === "nav-link") {
4509
+ try {
4510
+ const h = JSON.parse(item.notes ?? "{}").href;
4511
+ if (h) navHrefsAlready.add(h);
4512
+ } catch {
4513
+ }
4514
+ }
4515
+ }
4516
+ const gpGroups2 = /* @__PURE__ */ new Map();
4517
+ document.querySelectorAll("a[href]").forEach((a) => {
4518
+ if (!isNavVisible(a)) return;
4519
+ const href = a.href ?? "";
4520
+ if (!href || /^(javascript:|#$|mailto:|tel:)/i.test(href) || navHrefsAlready.has(href)) return;
4521
+ const label = (a.getAttribute("aria-label") || a.textContent?.replace(/\s+/g, " ").trim() || "").slice(0, 60);
4522
+ if (!label || label.length > 40) return;
4523
+ const parent = a.parentElement;
4524
+ const gp = parent?.parentElement;
4525
+ if (!parent || !gp) return;
4526
+ if (parent.querySelectorAll("a").length !== 1) return;
4527
+ const gpTag = gp.tagName.toLowerCase();
4528
+ if (["body", "html", "form"].includes(gpTag)) return;
4529
+ const arr = gpGroups2.get(gp) ?? [];
4530
+ arr.push({ label, href });
4531
+ gpGroups2.set(gp, arr);
4532
+ });
4533
+ for (const [, links] of gpGroups2.entries()) {
4534
+ if (links.length >= 4) {
4535
+ for (const { label, href } of links) {
4536
+ addUniq({ meaning: label, role: "nav-link", notes: JSON.stringify({ href: href.slice(0, 150), landmark: "Sidebar" }) });
4537
+ }
4538
+ }
4539
+ }
4540
+ }
3599
4541
  document.querySelectorAll('[aria-label*="breadcrumb" i] a, [class*="breadcrumb" i] a, nav[aria-label*="breadcrumb" i] a').forEach((a) => {
3600
4542
  const label = a.textContent?.replace(/\s+/g, " ").trim().slice(0, 60);
3601
4543
  const href = a.href;
@@ -4272,8 +5214,8 @@ async function expandNavMenus(page) {
4272
5214
  return false;
4273
5215
  }
4274
5216
  }
4275
- async function domFallbackLinks(page, origin) {
4276
- return page.evaluate((origin2) => {
5217
+ async function domFallbackLinks(page, origin, rawPage) {
5218
+ function _linkEval(originStr) {
4277
5219
  const logout = /logout|log out|sign out|delete/i;
4278
5220
  const isRouteHash = (hash) => /^#!?\//.test(hash);
4279
5221
  const seen = /* @__PURE__ */ new Set();
@@ -4285,7 +5227,7 @@ async function domFallbackLinks(page, origin) {
4285
5227
  try {
4286
5228
  const u = new URL(rawHref, location.href);
4287
5229
  if (!isRouteHash(u.hash)) u.hash = "";
4288
- if (new URL(u).origin === origin2) seen.add(u.toString());
5230
+ if (new URL(u).origin === originStr) seen.add(u.toString());
4289
5231
  } catch {
4290
5232
  }
4291
5233
  };
@@ -4316,7 +5258,39 @@ async function domFallbackLinks(page, origin) {
4316
5258
  }
4317
5259
  }
4318
5260
  return Array.from(seen);
4319
- }, origin).catch(() => []);
5261
+ }
5262
+ 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)})})();`;
5263
+ if (rawPage && typeof rawPage.context === "function") {
5264
+ try {
5265
+ const ctx = rawPage.context();
5266
+ if (typeof ctx?.newCDPSession === "function") {
5267
+ const cdp = await ctx.newCDPSession(rawPage);
5268
+ try {
5269
+ const evalResult = await cdp.send("Runtime.evaluate", {
5270
+ expression: _linkEvalStr,
5271
+ returnByValue: true,
5272
+ awaitPromise: false
5273
+ });
5274
+ if (!evalResult?.exceptionDetails && Array.isArray(evalResult?.result?.value)) {
5275
+ return evalResult.result.value;
5276
+ }
5277
+ if (evalResult?.exceptionDetails) {
5278
+ console.warn("[crawler] domFallbackLinks CDP error:", evalResult.exceptionDetails?.exception?.description ?? evalResult.exceptionDetails?.text);
5279
+ }
5280
+ } finally {
5281
+ cdp.detach().catch(() => {
5282
+ });
5283
+ }
5284
+ }
5285
+ } catch (cdpErr) {
5286
+ console.warn("[crawler] domFallbackLinks CDP failed:", cdpErr?.message ?? String(cdpErr));
5287
+ }
5288
+ }
5289
+ const evalPage = rawPage ?? page;
5290
+ return evalPage.evaluate(_linkEvalStr).catch((err) => {
5291
+ console.warn("[crawler] domFallbackLinks page.evaluate threw:", err?.message ?? String(err));
5292
+ return [];
5293
+ });
4320
5294
  }
4321
5295
  async function extractLinkMappings(page, origin) {
4322
5296
  return page.evaluate((origin2) => {
@@ -4417,50 +5391,105 @@ async function dismissOverlays(page) {
4417
5391
  } catch {
4418
5392
  }
4419
5393
  }
4420
- await page.waitForTimeout(300).catch(() => {
4421
- });
5394
+ await new Promise((r) => setTimeout(r, 300));
4422
5395
  }
4423
- async function scrollToTriggerLazyLoad(page) {
5396
+ async function scrollToTriggerLazyLoad(page, rawPage) {
5397
+ const evalPage = rawPage ?? page;
4424
5398
  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));
5399
+ const { scrollHeight, innerHeight, innerWidth } = await evalPage.evaluate(() => ({
5400
+ scrollHeight: Math.min(document.body.scrollHeight, 12e3),
5401
+ innerHeight: window.innerHeight || 800,
5402
+ innerWidth: window.innerWidth || 1280
5403
+ })).catch(() => ({ scrollHeight: 0, innerHeight: 800, innerWidth: 1280 }));
5404
+ if (scrollHeight > innerHeight) {
5405
+ let cdp = null;
5406
+ if (rawPage && typeof rawPage.context === "function") {
5407
+ try {
5408
+ const ctx = rawPage.context();
5409
+ if (typeof ctx?.newCDPSession === "function") cdp = await ctx.newCDPSession(rawPage);
5410
+ } catch {
5411
+ cdp = null;
5412
+ }
4431
5413
  }
4432
- window.scrollTo(0, 0);
4433
- });
4434
- await page.waitForTimeout(400).catch(() => {
4435
- });
4436
- await waitForStableDOM(page, 3e3, 300);
5414
+ const step = Math.max(innerHeight, 400);
5415
+ for (let y = step; y < scrollHeight; y += step) {
5416
+ await evalPage.evaluate((pos) => window.scrollTo(0, pos), y).catch(() => {
5417
+ });
5418
+ if (cdp) {
5419
+ await cdp.send("Input.dispatchMouseEvent", {
5420
+ type: "mouseWheel",
5421
+ x: Math.floor(innerWidth / 2),
5422
+ y: Math.floor(innerHeight / 2),
5423
+ deltaX: 0,
5424
+ deltaY: step,
5425
+ modifiers: 0
5426
+ }).catch(() => {
5427
+ });
5428
+ }
5429
+ await new Promise((r) => setTimeout(r, 150));
5430
+ }
5431
+ await evalPage.evaluate(() => window.scrollTo(0, 0)).catch(() => {
5432
+ });
5433
+ if (cdp) cdp.detach().catch(() => {
5434
+ });
5435
+ }
5436
+ await new Promise((r) => setTimeout(r, 400));
5437
+ const scrollWaitPage = rawPage ?? page;
5438
+ if (typeof scrollWaitPage.waitForLoadState === "function") {
5439
+ await scrollWaitPage.waitForLoadState("networkidle", { timeout: 5e3 }).catch(() => {
5440
+ });
5441
+ }
5442
+ await waitForStableDOM(page, 4e3, 500, rawPage);
4437
5443
  } catch {
4438
5444
  }
4439
5445
  }
4440
- async function waitForStableDOM(page, maxWait = 8e3, stabilityMs = 400) {
5446
+ async function waitForStableDOM(page, maxWait = 8e3, stabilityMs = 400, rawPage) {
5447
+ const evalPage = rawPage ?? page;
4441
5448
  const deadline = Date.now() + maxWait;
4442
5449
  let lastCount = -1;
4443
5450
  let stableAt = 0;
5451
+ let evalErrors = 0;
4444
5452
  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;
5453
+ const count = await evalPage.evaluate(() => {
5454
+ let c = 0;
5455
+ const visit = (root) => {
5456
+ const els = root.querySelectorAll("*");
5457
+ c += els.length;
5458
+ els.forEach((el) => {
5459
+ if (el.shadowRoot) visit(el.shadowRoot);
5460
+ });
5461
+ };
5462
+ visit(document);
5463
+ return c;
5464
+ }).catch(() => -1);
5465
+ if (count === -1) {
5466
+ if (++evalErrors >= 3) {
5467
+ await new Promise((r) => setTimeout(r, stabilityMs));
5468
+ return;
5469
+ }
5470
+ } else {
5471
+ evalErrors = 0;
5472
+ if (count !== lastCount) {
5473
+ lastCount = count;
5474
+ stableAt = Date.now();
5475
+ } else if (Date.now() - stableAt >= stabilityMs) {
5476
+ return;
5477
+ }
4451
5478
  }
4452
5479
  await new Promise((r) => setTimeout(r, 100));
4453
5480
  }
4454
5481
  }
4455
- async function waitForScreenshotReady(page) {
4456
- const hardDeadline = Date.now() + 2e4;
5482
+ async function waitForScreenshotReady(page, rawPage) {
5483
+ const hardDeadline = Date.now() + 3e4;
4457
5484
  const withHardCap = (p) => Promise.race([p, new Promise((resolve) => setTimeout(resolve, Math.max(0, hardDeadline - Date.now())))]);
5485
+ const waitPage = rawPage ?? page;
5486
+ const evalPage = rawPage ?? page;
4458
5487
  try {
4459
- await withHardCap(page.waitForLoadState("domcontentloaded", { timeout: 5e3 }).catch(() => {
5488
+ await withHardCap(waitPage.waitForLoadState("domcontentloaded", { timeout: 5e3 }).catch(() => {
4460
5489
  }));
4461
- await withHardCap(page.waitForLoadState("networkidle", { timeout: 12e3 }).catch(() => {
5490
+ await withHardCap(waitPage.waitForLoadState("networkidle", { timeout: 12e3 }).catch(() => {
4462
5491
  }));
4463
- await withHardCap(page.evaluate(() => {
5492
+ await withHardCap(evalPage.evaluate(() => {
4464
5493
  if (document.readyState === "complete") return true;
4465
5494
  return new Promise((resolve) => {
4466
5495
  const done = () => resolve(true);
@@ -4469,14 +5498,14 @@ async function waitForScreenshotReady(page) {
4469
5498
  });
4470
5499
  }).catch(() => {
4471
5500
  }));
4472
- await withHardCap(page.evaluate(async () => {
5501
+ await withHardCap(evalPage.evaluate(async () => {
4473
5502
  const fonts = document.fonts;
4474
5503
  if (fonts?.ready) await fonts.ready;
4475
5504
  }).catch(() => {
4476
5505
  }));
4477
- await withHardCap(scrollToTriggerLazyLoad(page));
4478
- await withHardCap(waitForStableDOM(page, 4e3, 700));
4479
- await withHardCap(page.evaluate(async () => {
5506
+ await withHardCap(scrollToTriggerLazyLoad(rawPage ?? page, rawPage));
5507
+ await withHardCap(waitForStableDOM(page, 4e3, 700, rawPage));
5508
+ await withHardCap(evalPage.evaluate(async () => {
4480
5509
  const pending = Array.from(document.images).filter((img) => !img.complete);
4481
5510
  if (pending.length === 0) return;
4482
5511
  await Promise.race([
@@ -4488,7 +5517,7 @@ async function waitForScreenshotReady(page) {
4488
5517
  ]);
4489
5518
  }).catch(() => {
4490
5519
  }));
4491
- await withHardCap(waitForStableDOM(page, 2e3, 500));
5520
+ await withHardCap(waitForStableDOM(page, 2e3, 500, rawPage));
4492
5521
  } catch {
4493
5522
  }
4494
5523
  }
@@ -4642,6 +5671,7 @@ async function captureAuthDiscoveryScreen(page, opts, requestedUrl, source, ordi
4642
5671
  return { captured: true, finalUrl, flowType };
4643
5672
  }
4644
5673
  async function runAuthDiscoveryPhase(page, opts, origin, onProgress) {
5674
+ const tuning = await getCrawlTuning();
4645
5675
  const queue = [];
4646
5676
  const queued = /* @__PURE__ */ new Set();
4647
5677
  const capturedKeys = /* @__PURE__ */ new Set();
@@ -4677,8 +5707,11 @@ async function runAuthDiscoveryPhase(page, opts, origin, onProgress) {
4677
5707
  await onProgress?.(10, "Discovering public and auth flows");
4678
5708
  while (queue.length > 0 && captured < AUTH_DISCOVERY_MAX_SCREENS) {
4679
5709
  const current = queue.shift();
5710
+ const _authPct = 10 + Math.round(captured / AUTH_DISCOVERY_MAX_SCREENS * 7);
5711
+ const _authPath = current.url.replace(/^https?:\/\/[^/]+/, "") || "/";
5712
+ await onProgress?.(_authPct, `Auth discovery \xB7 ${_authPath} \xB7 ${captured} found`);
4680
5713
  try {
4681
- const response = await page.goto(current.url, { waitUntil: "domcontentloaded", timeout: 15e3 }).catch(() => null);
5714
+ const response = await page.goto(current.url, { waitUntil: "domcontentloaded", timeout: tuning.navTimeoutMs }).catch(() => null);
4682
5715
  if (response && response.status && response.status() >= 400) continue;
4683
5716
  await page.waitForLoadState("networkidle", { timeout: 6e3 }).catch(() => {
4684
5717
  });