@detiq/crawler 0.1.3

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 (412) hide show
  1. package/Dockerfile +49 -0
  2. package/README.md +11 -0
  3. package/dist/accessibility-auditor.d.ts +29 -0
  4. package/dist/accessibility-auditor.js +57 -0
  5. package/dist/accessibility-auditor.js.map +7 -0
  6. package/dist/ai-login.d.ts +32 -0
  7. package/dist/ai-login.js +177 -0
  8. package/dist/ai-login.js.map +7 -0
  9. package/dist/auth-token-rotator.d.ts +47 -0
  10. package/dist/auth-token-rotator.js +58 -0
  11. package/dist/auth-token-rotator.js.map +7 -0
  12. package/dist/auth.d.ts +20 -0
  13. package/dist/auth.js +329 -0
  14. package/dist/auth.js.map +7 -0
  15. package/dist/browser-extension.d.ts +37 -0
  16. package/dist/browser-extension.js +113 -0
  17. package/dist/browser-extension.js.map +7 -0
  18. package/dist/captcha-solver.d.ts +16 -0
  19. package/dist/captcha-solver.js +105 -0
  20. package/dist/captcha-solver.js.map +7 -0
  21. package/dist/color-contrast.d.ts +30 -0
  22. package/dist/color-contrast.js +78 -0
  23. package/dist/color-contrast.js.map +7 -0
  24. package/dist/content-chunker.d.ts +18 -0
  25. package/dist/content-chunker.js +152 -0
  26. package/dist/content-chunker.js.map +7 -0
  27. package/dist/content-dedup.d.ts +23 -0
  28. package/dist/content-dedup.js +60 -0
  29. package/dist/content-dedup.js.map +7 -0
  30. package/dist/cookie-consent.d.ts +25 -0
  31. package/dist/cookie-consent.js +78 -0
  32. package/dist/cookie-consent.js.map +7 -0
  33. package/dist/cookie-jar.d.ts +20 -0
  34. package/dist/cookie-jar.js +61 -0
  35. package/dist/cookie-jar.js.map +7 -0
  36. package/dist/crawler.d.ts +179 -0
  37. package/dist/crawler.js +3672 -0
  38. package/dist/crawler.js.map +7 -0
  39. package/dist/custom-js-injector.d.ts +42 -0
  40. package/dist/custom-js-injector.js +50 -0
  41. package/dist/custom-js-injector.js.map +7 -0
  42. package/dist/dark-mode-capture.d.ts +23 -0
  43. package/dist/dark-mode-capture.js +75 -0
  44. package/dist/dark-mode-capture.js.map +7 -0
  45. package/dist/dead-link-detector.d.ts +37 -0
  46. package/dist/dead-link-detector.js +50 -0
  47. package/dist/dead-link-detector.js.map +7 -0
  48. package/dist/element-hash.d.ts +9 -0
  49. package/dist/element-hash.js +9 -0
  50. package/dist/element-hash.js.map +7 -0
  51. package/dist/email-otp.d.ts +42 -0
  52. package/dist/email-otp.js +177 -0
  53. package/dist/email-otp.js.map +7 -0
  54. package/dist/engine-router.d.ts +47 -0
  55. package/dist/engine-router.js +94 -0
  56. package/dist/engine-router.js.map +7 -0
  57. package/dist/export/bulk-export.d.ts +26 -0
  58. package/dist/export/bulk-export.js +76 -0
  59. package/dist/export/bulk-export.js.map +7 -0
  60. package/dist/export/vector-export.d.ts +76 -0
  61. package/dist/export/vector-export.js +85 -0
  62. package/dist/export/vector-export.js.map +7 -0
  63. package/dist/extractors/advanced.d.ts +54 -0
  64. package/dist/extractors/advanced.js +245 -0
  65. package/dist/extractors/advanced.js.map +7 -0
  66. package/dist/extractors/artifact-envelope.d.ts +63 -0
  67. package/dist/extractors/artifact-envelope.js +63 -0
  68. package/dist/extractors/artifact-envelope.js.map +7 -0
  69. package/dist/failure-analysis-agent.d.ts +16 -0
  70. package/dist/failure-analysis-agent.js +40 -0
  71. package/dist/failure-analysis-agent.js.map +7 -0
  72. package/dist/feature-flag-detector.d.ts +30 -0
  73. package/dist/feature-flag-detector.js +55 -0
  74. package/dist/feature-flag-detector.js.map +7 -0
  75. package/dist/form-detector.d.ts +34 -0
  76. package/dist/form-detector.js +58 -0
  77. package/dist/form-detector.js.map +7 -0
  78. package/dist/frontier.d.ts +32 -0
  79. package/dist/frontier.js +83 -0
  80. package/dist/frontier.js.map +7 -0
  81. package/dist/gap-pass.d.ts +35 -0
  82. package/dist/gap-pass.js +81 -0
  83. package/dist/gap-pass.js.map +7 -0
  84. package/dist/graphql-detector.d.ts +39 -0
  85. package/dist/graphql-detector.js +86 -0
  86. package/dist/graphql-detector.js.map +7 -0
  87. package/dist/http-engine.d.ts +80 -0
  88. package/dist/http-engine.js +304 -0
  89. package/dist/http-engine.js.map +7 -0
  90. package/dist/incremental-diff.d.ts +59 -0
  91. package/dist/incremental-diff.js +61 -0
  92. package/dist/incremental-diff.js.map +7 -0
  93. package/dist/index.d.ts +35 -0
  94. package/dist/index.js +61 -0
  95. package/dist/index.js.map +7 -0
  96. package/dist/input-fuzzer.d.ts +30 -0
  97. package/dist/input-fuzzer.js +105 -0
  98. package/dist/input-fuzzer.js.map +7 -0
  99. package/dist/interactive-probe.d.ts +104 -0
  100. package/dist/interactive-probe.js +442 -0
  101. package/dist/interactive-probe.js.map +7 -0
  102. package/dist/job-queue.d.ts +171 -0
  103. package/dist/job-queue.js +2256 -0
  104. package/dist/job-queue.js.map +7 -0
  105. package/dist/js-coverage.d.ts +29 -0
  106. package/dist/js-coverage.js +56 -0
  107. package/dist/js-coverage.js.map +7 -0
  108. package/dist/js-router-detector.d.ts +17 -0
  109. package/dist/js-router-detector.js +90 -0
  110. package/dist/js-router-detector.js.map +7 -0
  111. package/dist/language-detector.d.ts +22 -0
  112. package/dist/language-detector.js +57 -0
  113. package/dist/language-detector.js.map +7 -0
  114. package/dist/link-classifier.d.ts +23 -0
  115. package/dist/link-classifier.js +40 -0
  116. package/dist/link-classifier.js.map +7 -0
  117. package/dist/live-view.d.ts +36 -0
  118. package/dist/live-view.js +100 -0
  119. package/dist/live-view.js.map +7 -0
  120. package/dist/logger.d.ts +2 -0
  121. package/dist/logger.js +10 -0
  122. package/dist/logger.js.map +7 -0
  123. package/dist/markdown-extract.d.ts +8 -0
  124. package/dist/markdown-extract.js +54 -0
  125. package/dist/markdown-extract.js.map +7 -0
  126. package/dist/native-crawler.d.ts +69 -0
  127. package/dist/native-crawler.js +839 -0
  128. package/dist/native-crawler.js.map +7 -0
  129. package/dist/network-observer.d.ts +49 -0
  130. package/dist/network-observer.js +149 -0
  131. package/dist/network-observer.js.map +7 -0
  132. package/dist/network-throttle.d.ts +25 -0
  133. package/dist/network-throttle.js +34 -0
  134. package/dist/network-throttle.js.map +7 -0
  135. package/dist/openapi-generator.d.ts +49 -0
  136. package/dist/openapi-generator.js +89 -0
  137. package/dist/openapi-generator.js.map +7 -0
  138. package/dist/pdf-extractor.d.ts +28 -0
  139. package/dist/pdf-extractor.js +81 -0
  140. package/dist/pdf-extractor.js.map +7 -0
  141. package/dist/performance-metrics.d.ts +53 -0
  142. package/dist/performance-metrics.js +83 -0
  143. package/dist/performance-metrics.js.map +7 -0
  144. package/dist/phash.d.ts +38 -0
  145. package/dist/phash.js +115 -0
  146. package/dist/phash.js.map +7 -0
  147. package/dist/pii-scrubber.d.ts +21 -0
  148. package/dist/pii-scrubber.js +75 -0
  149. package/dist/pii-scrubber.js.map +7 -0
  150. package/dist/playwright-recorder.d.ts +39 -0
  151. package/dist/playwright-recorder.js +123 -0
  152. package/dist/playwright-recorder.js.map +7 -0
  153. package/dist/policies/crawl-safety.d.ts +9 -0
  154. package/dist/policies/crawl-safety.js +37 -0
  155. package/dist/policies/crawl-safety.js.map +7 -0
  156. package/dist/profiles/domain-profile.d.ts +19 -0
  157. package/dist/profiles/domain-profile.js +140 -0
  158. package/dist/profiles/domain-profile.js.map +7 -0
  159. package/dist/proxy.d.ts +50 -0
  160. package/dist/proxy.js +165 -0
  161. package/dist/proxy.js.map +7 -0
  162. package/dist/rate-limiter.d.ts +31 -0
  163. package/dist/rate-limiter.js +64 -0
  164. package/dist/rate-limiter.js.map +7 -0
  165. package/dist/redirect-tracker.d.ts +37 -0
  166. package/dist/redirect-tracker.js +65 -0
  167. package/dist/redirect-tracker.js.map +7 -0
  168. package/dist/redis-frontier.d.ts +48 -0
  169. package/dist/redis-frontier.js +126 -0
  170. package/dist/redis-frontier.js.map +7 -0
  171. package/dist/redis-queue.d.ts +48 -0
  172. package/dist/redis-queue.js +165 -0
  173. package/dist/redis-queue.js.map +7 -0
  174. package/dist/response-cache.d.ts +38 -0
  175. package/dist/response-cache.js +140 -0
  176. package/dist/response-cache.js.map +7 -0
  177. package/dist/responsive-detector.d.ts +29 -0
  178. package/dist/responsive-detector.js +46 -0
  179. package/dist/responsive-detector.js.map +7 -0
  180. package/dist/retry-policy.d.ts +20 -0
  181. package/dist/retry-policy.js +54 -0
  182. package/dist/retry-policy.js.map +7 -0
  183. package/dist/robots-txt.d.ts +9 -0
  184. package/dist/robots-txt.js +77 -0
  185. package/dist/robots-txt.js.map +7 -0
  186. package/dist/screen-reader.d.ts +16 -0
  187. package/dist/screen-reader.js +46 -0
  188. package/dist/screen-reader.js.map +7 -0
  189. package/dist/screenshot-masker.d.ts +28 -0
  190. package/dist/screenshot-masker.js +62 -0
  191. package/dist/screenshot-masker.js.map +7 -0
  192. package/dist/seo-checker.d.ts +33 -0
  193. package/dist/seo-checker.js +91 -0
  194. package/dist/seo-checker.js.map +7 -0
  195. package/dist/session-capture.d.ts +35 -0
  196. package/dist/session-capture.js +267 -0
  197. package/dist/session-capture.js.map +7 -0
  198. package/dist/session-export.d.ts +49 -0
  199. package/dist/session-export.js +117 -0
  200. package/dist/session-export.js.map +7 -0
  201. package/dist/sitemap-writer.d.ts +21 -0
  202. package/dist/sitemap-writer.js +47 -0
  203. package/dist/sitemap-writer.js.map +7 -0
  204. package/dist/sitemap.d.ts +51 -0
  205. package/dist/sitemap.js +152 -0
  206. package/dist/sitemap.js.map +7 -0
  207. package/dist/skyvern.d.ts +56 -0
  208. package/dist/skyvern.js +129 -0
  209. package/dist/skyvern.js.map +7 -0
  210. package/dist/sqs-queue.d.ts +2 -0
  211. package/dist/sqs-queue.js +47 -0
  212. package/dist/sqs-queue.js.map +7 -0
  213. package/dist/sso-token.d.ts +29 -0
  214. package/dist/sso-token.js +76 -0
  215. package/dist/sso-token.js.map +7 -0
  216. package/dist/stealth/human-behavior.d.ts +20 -0
  217. package/dist/stealth/human-behavior.js +168 -0
  218. package/dist/stealth/human-behavior.js.map +7 -0
  219. package/dist/telemetry.d.ts +34 -0
  220. package/dist/telemetry.js +77 -0
  221. package/dist/telemetry.js.map +7 -0
  222. package/dist/test-executor.d.ts +67 -0
  223. package/dist/test-executor.js +311 -0
  224. package/dist/test-executor.js.map +7 -0
  225. package/dist/tls-fingerprint.d.ts +50 -0
  226. package/dist/tls-fingerprint.js +81 -0
  227. package/dist/tls-fingerprint.js.map +7 -0
  228. package/dist/url-pattern-completer.d.ts +28 -0
  229. package/dist/url-pattern-completer.js +87 -0
  230. package/dist/url-pattern-completer.js.map +7 -0
  231. package/dist/visual-diff-verdict.d.ts +40 -0
  232. package/dist/visual-diff-verdict.js +25 -0
  233. package/dist/visual-diff-verdict.js.map +7 -0
  234. package/dist/webhook-notifier.d.ts +30 -0
  235. package/dist/webhook-notifier.js +76 -0
  236. package/dist/webhook-notifier.js.map +7 -0
  237. package/dist/websocket-detector.d.ts +24 -0
  238. package/dist/websocket-detector.js +60 -0
  239. package/dist/websocket-detector.js.map +7 -0
  240. package/dist/worker.d.ts +1 -0
  241. package/dist/worker.js +33 -0
  242. package/dist/worker.js.map +7 -0
  243. package/package.json +45 -0
  244. package/src/accessibility-auditor.ts +102 -0
  245. package/src/ai-login.ts +218 -0
  246. package/src/auth-token-rotator.ts +89 -0
  247. package/src/auth.ts +396 -0
  248. package/src/browser-extension.ts +186 -0
  249. package/src/captcha-solver.ts +143 -0
  250. package/src/color-contrast.ts +116 -0
  251. package/src/content-chunker.ts +170 -0
  252. package/src/content-dedup.ts +99 -0
  253. package/src/cookie-consent.ts +106 -0
  254. package/src/cookie-jar.ts +91 -0
  255. package/src/crawler.ts +4305 -0
  256. package/src/custom-js-injector.ts +93 -0
  257. package/src/dark-mode-capture.ts +110 -0
  258. package/src/dead-link-detector.ts +76 -0
  259. package/src/element-hash.ts +16 -0
  260. package/src/email-otp.ts +252 -0
  261. package/src/engine-router.ts +141 -0
  262. package/src/export/bulk-export.ts +85 -0
  263. package/src/export/vector-export.ts +160 -0
  264. package/src/extractors/advanced.ts +283 -0
  265. package/src/extractors/artifact-envelope.ts +111 -0
  266. package/src/failure-analysis-agent.ts +49 -0
  267. package/src/feature-flag-detector.ts +97 -0
  268. package/src/form-detector.ts +103 -0
  269. package/src/frontier.test.ts +64 -0
  270. package/src/frontier.ts +112 -0
  271. package/src/gap-pass.ts +122 -0
  272. package/src/graphql-detector.ts +129 -0
  273. package/src/http-engine.ts +433 -0
  274. package/src/incremental-diff.ts +121 -0
  275. package/src/index.ts +35 -0
  276. package/src/input-fuzzer.ts +129 -0
  277. package/src/interactive-probe.ts +623 -0
  278. package/src/job-queue.test.ts +120 -0
  279. package/src/job-queue.ts +2701 -0
  280. package/src/js-coverage.ts +87 -0
  281. package/src/js-router-detector.ts +112 -0
  282. package/src/language-detector.ts +82 -0
  283. package/src/link-classifier.ts +65 -0
  284. package/src/live-view.ts +141 -0
  285. package/src/logger.ts +9 -0
  286. package/src/markdown-extract.test.ts +73 -0
  287. package/src/markdown-extract.ts +76 -0
  288. package/src/native-crawler.ts +1106 -0
  289. package/src/network-observer.ts +204 -0
  290. package/src/network-throttle.ts +69 -0
  291. package/src/openapi-generator.ts +135 -0
  292. package/src/pdf-extractor.ts +123 -0
  293. package/src/performance-metrics.ts +134 -0
  294. package/src/phash.ts +151 -0
  295. package/src/pii-scrubber.ts +99 -0
  296. package/src/playwright-recorder.ts +170 -0
  297. package/src/policies/crawl-safety.ts +46 -0
  298. package/src/profiles/domain-profile.ts +167 -0
  299. package/src/proxy.ts +238 -0
  300. package/src/rate-limiter.ts +87 -0
  301. package/src/redirect-tracker.ts +97 -0
  302. package/src/redis-frontier.ts +143 -0
  303. package/src/redis-queue.ts +208 -0
  304. package/src/response-cache.ts +150 -0
  305. package/src/responsive-detector.ts +92 -0
  306. package/src/retry-policy.ts +87 -0
  307. package/src/robots-txt.ts +96 -0
  308. package/src/screen-reader.ts +89 -0
  309. package/src/screenshot-masker.ts +98 -0
  310. package/src/seo-checker.ts +134 -0
  311. package/src/session-capture.ts +325 -0
  312. package/src/session-export.test.ts +75 -0
  313. package/src/session-export.ts +172 -0
  314. package/src/sitemap-writer.ts +65 -0
  315. package/src/sitemap.test.ts +131 -0
  316. package/src/sitemap.ts +224 -0
  317. package/src/skyvern.ts +199 -0
  318. package/src/sqs-queue.ts +49 -0
  319. package/src/sso-token.ts +120 -0
  320. package/src/stealth/human-behavior.ts +191 -0
  321. package/src/telemetry.ts +122 -0
  322. package/src/test-executor.test.ts +72 -0
  323. package/src/test-executor.ts +424 -0
  324. package/src/tls-fingerprint.ts +111 -0
  325. package/src/url-pattern-completer.ts +114 -0
  326. package/src/visual-diff-verdict.test.ts +80 -0
  327. package/src/visual-diff-verdict.ts +57 -0
  328. package/src/webhook-notifier.ts +109 -0
  329. package/src/websocket-detector.ts +83 -0
  330. package/src/worker.ts +48 -0
  331. package/tsconfig.json +13 -0
  332. package/types/accessibility-auditor.d.ts +29 -0
  333. package/types/ai-login.d.ts +32 -0
  334. package/types/auth-token-rotator.d.ts +47 -0
  335. package/types/auth.d.ts +20 -0
  336. package/types/browser-extension.d.ts +37 -0
  337. package/types/bull-worker.d.ts +6 -0
  338. package/types/captcha-solver.d.ts +16 -0
  339. package/types/color-contrast.d.ts +30 -0
  340. package/types/content-chunker.d.ts +18 -0
  341. package/types/content-dedup.d.ts +23 -0
  342. package/types/cookie-consent.d.ts +25 -0
  343. package/types/cookie-jar.d.ts +20 -0
  344. package/types/crawler.d.ts +179 -0
  345. package/types/custom-js-injector.d.ts +42 -0
  346. package/types/dark-mode-capture.d.ts +23 -0
  347. package/types/dead-link-detector.d.ts +37 -0
  348. package/types/element-hash.d.ts +9 -0
  349. package/types/email-otp.d.ts +42 -0
  350. package/types/engine-router.d.ts +47 -0
  351. package/types/export/bulk-export.d.ts +26 -0
  352. package/types/export/vector-export.d.ts +76 -0
  353. package/types/extractors/advanced.d.ts +54 -0
  354. package/types/extractors/artifact-envelope.d.ts +63 -0
  355. package/types/failure-analysis-agent.d.ts +16 -0
  356. package/types/feature-flag-detector.d.ts +30 -0
  357. package/types/form-detector.d.ts +34 -0
  358. package/types/frontier.d.ts +32 -0
  359. package/types/gap-pass.d.ts +35 -0
  360. package/types/graphql-detector.d.ts +39 -0
  361. package/types/http-engine.d.ts +80 -0
  362. package/types/incremental-diff.d.ts +59 -0
  363. package/types/index.d.ts +35 -0
  364. package/types/input-fuzzer.d.ts +30 -0
  365. package/types/interactive-probe.d.ts +104 -0
  366. package/types/job-queue.d.ts +171 -0
  367. package/types/js-coverage.d.ts +29 -0
  368. package/types/js-router-detector.d.ts +17 -0
  369. package/types/language-detector.d.ts +22 -0
  370. package/types/link-classifier.d.ts +23 -0
  371. package/types/live-view.d.ts +36 -0
  372. package/types/logger.d.ts +2 -0
  373. package/types/markdown-extract.d.ts +8 -0
  374. package/types/native-crawler.d.ts +69 -0
  375. package/types/network-observer.d.ts +49 -0
  376. package/types/network-throttle.d.ts +25 -0
  377. package/types/openapi-generator.d.ts +49 -0
  378. package/types/pdf-extractor.d.ts +28 -0
  379. package/types/performance-metrics.d.ts +53 -0
  380. package/types/phash.d.ts +38 -0
  381. package/types/pii-scrubber.d.ts +21 -0
  382. package/types/playwright-recorder.d.ts +39 -0
  383. package/types/policies/crawl-safety.d.ts +9 -0
  384. package/types/profiles/domain-profile.d.ts +19 -0
  385. package/types/proxy.d.ts +50 -0
  386. package/types/rate-limiter.d.ts +31 -0
  387. package/types/redirect-tracker.d.ts +37 -0
  388. package/types/redis-frontier.d.ts +48 -0
  389. package/types/redis-queue.d.ts +48 -0
  390. package/types/response-cache.d.ts +38 -0
  391. package/types/responsive-detector.d.ts +29 -0
  392. package/types/retry-policy.d.ts +20 -0
  393. package/types/robots-txt.d.ts +9 -0
  394. package/types/screen-reader.d.ts +16 -0
  395. package/types/screenshot-masker.d.ts +28 -0
  396. package/types/seo-checker.d.ts +33 -0
  397. package/types/session-capture.d.ts +38 -0
  398. package/types/session-export.d.ts +49 -0
  399. package/types/sitemap-writer.d.ts +21 -0
  400. package/types/sitemap.d.ts +51 -0
  401. package/types/skyvern.d.ts +56 -0
  402. package/types/sqs-queue.d.ts +2 -0
  403. package/types/sso-token.d.ts +29 -0
  404. package/types/stealth/human-behavior.d.ts +20 -0
  405. package/types/telemetry.d.ts +34 -0
  406. package/types/test-executor.d.ts +67 -0
  407. package/types/tls-fingerprint.d.ts +50 -0
  408. package/types/url-pattern-completer.d.ts +28 -0
  409. package/types/visual-diff-verdict.d.ts +40 -0
  410. package/types/webhook-notifier.d.ts +30 -0
  411. package/types/websocket-detector.d.ts +24 -0
  412. package/types/worker.d.ts +1 -0
package/src/crawler.ts ADDED
@@ -0,0 +1,4305 @@
1
+ /**
2
+ * ZeTa-AI Crawler — Stagehand hybrid architecture
3
+ *
4
+ * Three-layer stack:
5
+ * Layer 1 — Playwright (deterministic): navigation, screenshots, DOM fallbacks
6
+ * Layer 2 — Stagehand act/extract (AI): login, element understanding, smart link discovery
7
+ * Layer 3 — Skyvern (vision): fallback for CAPTCHAs, legacy apps, visual-only flows
8
+ *
9
+ * enableCaching: true → repeat crawls of the same site cost ~zero AI tokens after first run.
10
+ */
11
+
12
+ import { Stagehand } from '@browserbasehq/stagehand';
13
+ // Option 2: rebrowser-playwright-core patches the CDP Runtime.enable leak at protocol level —
14
+ // the deepest possible stealth, defeats detection methods that JS init scripts cannot touch.
15
+ // @ts-ignore
16
+ import { chromium } from 'rebrowser-playwright-core';
17
+ import { chromium as stealthChromium } from 'playwright-extra';
18
+ // @ts-ignore
19
+ import StealthPlugin from 'puppeteer-extra-plugin-stealth';
20
+ // Option 3: Camoufox — Firefox-based browser with built-in fingerprint randomisation.
21
+ // Loaded dynamically (not at startup) to avoid ESM/CJS conflict when API imports crawler.
22
+ import { spawn, execSync, type ChildProcess } from 'node:child_process';
23
+ import { readFileSync } from 'node:fs';
24
+ import { createRequire } from 'node:module';
25
+
26
+ stealthChromium.use(StealthPlugin());
27
+ import { z } from 'zod';
28
+ import fs from 'node:fs/promises';
29
+ import path from 'node:path';
30
+ import { AppBrain, recordLLMCall } from '@detiq/app-brain';
31
+ import { aiVisualCompare, askTracked, estimateCost, getTaskAgentLLMConfig } from '@detiq/agents';
32
+ import { config } from '@detiq/core';
33
+ import { pHashVerdict, mergeAiVisualVerdict } from './visual-diff-verdict.js';
34
+ import type { Credentials } from './auth.js';
35
+ import { skyvernFallback, skyvernNavigate, type SkyvernConfig } from './skyvern.js';
36
+ import { loginWithStagehand } from './ai-login.js';
37
+ import { readScreen } from './screen-reader.js';
38
+ import { probeAllScreens } from './interactive-probe.js';
39
+ import { computeElementHash } from './element-hash.js';
40
+ import { logger } from './logger.js';
41
+ import { solveCaptcha } from './captcha-solver.js';
42
+ import { waitForEmailOTP } from './email-otp.js';
43
+ import { getSsoToken } from './sso-token.js';
44
+ import { discoverSitemapUrls, sameSite } from './sitemap.js';
45
+ import { extractMarkdown } from './markdown-extract.js';
46
+ import { CrawlFrontier, type TraversalStrategy } from './frontier.js';
47
+ import { RobotsCache } from './robots-txt.js';
48
+ import { classifyDomainProfile } from './profiles/domain-profile.js';
49
+ import { chooseCrawlEngines } from './engine-router.js';
50
+ import { assessActionSafety } from './policies/crawl-safety.js';
51
+ import { attachNetworkObserver, type ApiEndpointObservationInput } from './network-observer.js';
52
+ import { scrubPii } from './pii-scrubber.js';
53
+ import { computeContentHash } from './incremental-diff.js';
54
+ import { isPdfUrl, extractPdfFromUrl } from './pdf-extractor.js';
55
+ import { attachWebSocketDetector } from './websocket-detector.js';
56
+ import { ContentDeduplicator } from './content-dedup.js';
57
+ import { guessGraphQLEndpoints, introspectGraphQL } from './graphql-detector.js';
58
+ import { acceptCookieConsent } from './cookie-consent.js';
59
+ import { detectJsRoutes } from './js-router-detector.js';
60
+ import { runGapAnalysis } from './gap-pass.js';
61
+ import { buildCrawlArtifactEnvelope, buildFitMarkdown, sha256, summarizeStructuredElements } from './extractors/artifact-envelope.js';
62
+ import { pickFreeProxy, TieredProxyManager } from './proxy.js';
63
+ import type { ProxyTierConfig } from './proxy.js';
64
+ import { infiniteScrollToBottom, extractIframeContent, pierceShadowDom, injectFingerprintOverrides } from './stealth/human-behavior.js';
65
+ import { checkSeo } from './seo-checker.js';
66
+ import { auditColorContrast } from './color-contrast.js';
67
+ import { detectFeatureFlags } from './feature-flag-detector.js';
68
+ import { detectLanguage } from './language-detector.js';
69
+ import { detectForms } from './form-detector.js';
70
+ import { RedirectRegistry } from './redirect-tracker.js';
71
+ import { DeadLinkTracker } from './dead-link-detector.js';
72
+ import { screensToSitemapUrls, writeSitemap } from './sitemap-writer.js';
73
+ import { generateOpenApiSpec, specToYaml } from './openapi-generator.js';
74
+ import { WebhookNotifier } from './webhook-notifier.js';
75
+ import { generatePlaywrightTest } from './playwright-recorder.js';
76
+
77
+ function getGitInfo(): { branch: string; commit: string } {
78
+ try {
79
+ const branch = execSync('git rev-parse --abbrev-ref HEAD', { encoding: 'utf8', timeout: 3000 }).trim();
80
+ const commit = execSync('git rev-parse HEAD', { encoding: 'utf8', timeout: 3000 }).trim().slice(0, 12);
81
+ return { branch: branch || 'main', commit };
82
+ } catch {
83
+ return { branch: 'main', commit: '' };
84
+ }
85
+ }
86
+
87
+ interface PerformanceMetric {
88
+ url: string;
89
+ screenName: string;
90
+ lcp?: number;
91
+ fcp?: number;
92
+ ttfb?: number;
93
+ cls?: number;
94
+ }
95
+
96
+ const ALLOWED_NUMERIC_PATHS = new Set(['/2fa', '/404', '/500', '/403', '/401']);
97
+ const UUID_PATH_SEGMENT_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
98
+ const CUID_PATH_SEGMENT_RE = /^c[a-z0-9]{20,}$/i;
99
+ const NUMERIC_ID_PATH_SEGMENT_RE = /^\d[\d-]*$/;
100
+ const COMPOUND_NUMERIC_ID_PATH_SEGMENT_RE = /^\d+-\d+$/;
101
+
102
+ function normalizePathForNumericGuard(pathname: string): string {
103
+ const normalized = pathname.replace(/\/+$/, '') || '/';
104
+ return normalized;
105
+ }
106
+
107
+ function isGeneratedPathSegment(segment: string): boolean {
108
+ return UUID_PATH_SEGMENT_RE.test(segment)
109
+ || CUID_PATH_SEGMENT_RE.test(segment)
110
+ || COMPOUND_NUMERIC_ID_PATH_SEGMENT_RE.test(segment)
111
+ || NUMERIC_ID_PATH_SEGMENT_RE.test(segment);
112
+ }
113
+
114
+ function isGeneratedDynamicPath(pathname: string): boolean {
115
+ const normalized = normalizePathForNumericGuard(pathname);
116
+ if (ALLOWED_NUMERIC_PATHS.has(normalized)) return false;
117
+
118
+ // Parent-route agnostic: any generated ID-like segment can create hundreds of
119
+ // duplicate detail/fallback screens under different sections of the app.
120
+ return normalized.split('/').filter(Boolean).some(isGeneratedPathSegment);
121
+ }
122
+
123
+ function isUsefulNavigationUrl(href: string, siteOrigin: string): boolean {
124
+ try {
125
+ const u = new URL(href);
126
+ if (!sameSite(u.toString(), siteOrigin)) return false;
127
+ if (/\/(logout|logged-out|signout|sign-out)(\/|$|\?)/i.test(u.pathname)) return false;
128
+ if (isGeneratedDynamicPath(u.pathname)) return false;
129
+ return true;
130
+ } catch {
131
+ return false;
132
+ }
133
+ }
134
+
135
+ type AuthFlowType = 'public' | 'login' | 'register' | 'password_reset' | 'verification' | 'invite' | 'session_recovery';
136
+ type AuthDiscoverySource = 'entry' | 'seed_path' | 'page_link' | 'auth_click';
137
+ type AuthDiscoveryProgress = (pct: number, step: string) => void | Promise<void>;
138
+ type AuthDiscoveryOptions = {
139
+ appUrl: string;
140
+ tenantId: string;
141
+ projectId: string;
142
+ jobId?: string;
143
+ screenshotDir?: string;
144
+ };
145
+
146
+ const AUTH_DISCOVERY_MAX_SCREENS = 36;
147
+ const AUTH_DISCOVERY_MAX_QUEUE = 48;
148
+ const AUTH_DISCOVERY_MAX_CLICKS_PER_PAGE = 6;
149
+ const AUTH_DISCOVERY_PATHS = [
150
+ '/',
151
+ '/login', '/log-in', '/signin', '/sign-in', '/auth', '/auth/login', '/account/login', '/user/login', '/app/login',
152
+ '/register', '/signup', '/sign-up', '/create-account', '/join',
153
+ '/forgot-password', '/forgot', '/password/forgot', '/reset-password', '/password/reset',
154
+ '/verify-email', '/invite',
155
+ '/pricing', '/contact', '/about', '/help', '/docs',
156
+ ];
157
+ const AUTH_PATH_RE = /\/(login|log-in|signin|sign-in|auth|oauth|sso|account\/login|user\/login|app\/login|register|signup|sign-up|create-account|forgot|reset|password|verify-email|invite)(\/|$|\?)/i;
158
+ const PUBLIC_PATH_RE = /\/($|pricing|contact|about|help|docs|features|security|privacy|terms)(\/|$|\?)/i;
159
+
160
+ function compactSlug(input: string, fallback = 'state'): string {
161
+ const slug = input.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 48);
162
+ return slug || fallback;
163
+ }
164
+
165
+ function titleFromUrl(url: string, fallback = 'Page'): string {
166
+ try {
167
+ const u = new URL(url);
168
+ const parts = u.pathname.split('/').filter(Boolean);
169
+ if (parts.length === 0) return 'Home';
170
+ return parts
171
+ .slice(-2)
172
+ .join(' ')
173
+ .replace(/[-_]+/g, ' ')
174
+ .replace(/\b\w/g, (m) => m.toUpperCase());
175
+ } catch {
176
+ return fallback;
177
+ }
178
+ }
179
+
180
+ function flowTypeForUrlOrText(value: string): AuthFlowType {
181
+ const v = value.toLowerCase();
182
+ if (/(forgot|reset|password)/.test(v)) return 'password_reset';
183
+ if (/(register|signup|sign up|create account|join)/.test(v)) return 'register';
184
+ if (/(verify|verification|otp|mfa|2fa|invite)/.test(v)) return v.includes('invite') ? 'invite' : 'verification';
185
+ if (/(session-expired|expired|unauthorized|forbidden|401|403|logout|logged-out)/.test(v)) return 'session_recovery';
186
+ if (/(login|log in|signin|sign in|auth|sso|oauth)/.test(v)) return 'login';
187
+ return 'public';
188
+ }
189
+
190
+ function shouldSeedAuthDiscoveryUrl(url: string, origin: string): boolean {
191
+ try {
192
+ const u = new URL(url);
193
+ if (!sameSite(u.toString(), origin)) return false;
194
+ if (/\/(logout|logged-out|signout|sign-out|delete|remove|destroy)(\/|$|\?)/i.test(u.pathname)) return false;
195
+ return AUTH_PATH_RE.test(u.pathname) || PUBLIC_PATH_RE.test(u.pathname);
196
+ } catch {
197
+ return false;
198
+ }
199
+ }
200
+
201
+ function normalizeAuthUrl(url: string): string {
202
+ try {
203
+ const u = new URL(url);
204
+ u.hash = '';
205
+ return `${u.origin}${u.pathname.replace(/\/$/, '') || '/'}${u.search}`;
206
+ } catch {
207
+ return url;
208
+ }
209
+ }
210
+
211
+ async function capturePageHtml(page: any): Promise<string | null> {
212
+ try {
213
+ if (typeof page.content === 'function') {
214
+ const html = await page.content();
215
+ if (typeof html === 'string' && html.trim()) return html;
216
+ }
217
+ } catch (err: any) {
218
+ logger.warn({ err: String(err?.message ?? err) }, '[crawler] page.content() failed; trying DOM evaluate fallback');
219
+ }
220
+
221
+ try {
222
+ const html = await page.evaluate(() => {
223
+ const type = document.doctype;
224
+ const doctype = type
225
+ ? '<!DOCTYPE ' + type.name +
226
+ (type.publicId ? ' PUBLIC "' + type.publicId + '"' : '') +
227
+ (type.systemId ? ' "' + type.systemId + '"' : '') +
228
+ '>'
229
+ : '<!DOCTYPE html>';
230
+ return doctype + '\n' + (document.documentElement?.outerHTML ?? document.body?.outerHTML ?? '');
231
+ });
232
+ return typeof html === 'string' && html.trim() ? html : null;
233
+ } catch (err: any) {
234
+ logger.warn({ err: String(err?.message ?? err) }, '[crawler] DOM snapshot capture failed');
235
+ return null;
236
+ }
237
+ }
238
+
239
+ /** Launch Chrome on a random CDP port and return its WebSocket debugger URL. */
240
+ async function launchChromeCDP(
241
+ execPath: string,
242
+ extraArgs: string[],
243
+ proxy?: { server: string; username?: string; password?: string },
244
+ ): Promise<{ wsUrl: string; proc: any }> {
245
+ // Use a random port in 9200-9900 range to avoid clashes between concurrent crawls.
246
+ const port = 9200 + Math.floor(Math.random() * 700);
247
+
248
+ const browserServer = await stealthChromium.launchServer({
249
+ headless: true,
250
+ executablePath: execPath,
251
+ // Use Playwright's native proxy option — it handles 407 auth challenges automatically.
252
+ // The --proxy-server Chrome flag does NOT support credentials with special chars (commas).
253
+ ...(proxy ? { proxy } : {}),
254
+ args: [
255
+ ...extraArgs,
256
+ `--remote-debugging-port=${port}`,
257
+ '--remote-allow-origins=*',
258
+ '--no-first-run',
259
+ '--no-default-browser-check',
260
+ ],
261
+ });
262
+
263
+ // Poll until Chrome's debug port is ready (up to 10 s).
264
+ const deadline = Date.now() + 10_000;
265
+ while (Date.now() < deadline) {
266
+ await new Promise((r) => setTimeout(r, 300));
267
+ try {
268
+ const res = await fetch(`http://127.0.0.1:${port}/json/version`);
269
+ if (res.ok) {
270
+ const { webSocketDebuggerUrl } = await res.json() as { webSocketDebuggerUrl: string };
271
+ return { wsUrl: webSocketDebuggerUrl, proc: browserServer };
272
+ }
273
+ } catch { /* not ready */ }
274
+ }
275
+ await browserServer.close();
276
+ throw new Error(`Chrome failed to open CDP port ${port} within 10 s`);
277
+ }
278
+
279
+ /**
280
+ * Lightweight pre-crawl link discovery: loads the app URL in a headless (proxy-aware) browser
281
+ * and returns same-site `<a href>` links. Complements sitemap discovery for SPA/app sites that
282
+ * have no sitemap.xml. Best-effort — returns [] on any failure (bot wall, timeout, etc.).
283
+ * chromium.launch({ proxy }) handles proxy 407 auth itself (same client), so no CDP handler needed.
284
+ */
285
+ export async function discoverLinksViaBrowser(
286
+ appUrl: string,
287
+ opts?: { proxyUrl?: string; maxLinks?: number },
288
+ ): Promise<string[]> {
289
+ let proxy: { server: string; username?: string; password?: string } | undefined;
290
+ const purl = opts?.proxyUrl;
291
+ if (purl && purl !== 'auto') {
292
+ const m = purl.match(/^(https?|socks5?):\/\/(?:([^:@]*)(?::([^@]*))?@)?(.+)$/);
293
+ if (m) {
294
+ const [, scheme, user, pass, hostPort] = m;
295
+ proxy = user
296
+ ? { server: `${scheme}://${hostPort}`, username: decodeURIComponent(user), password: decodeURIComponent(pass ?? '') }
297
+ : { server: `${scheme}://${hostPort}` };
298
+ } else {
299
+ proxy = { server: purl };
300
+ }
301
+ }
302
+ let executablePath: string | undefined;
303
+ try { executablePath = chromium.executablePath(); } catch { /* fall back to CHROME_PATH */ }
304
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
305
+ let browser: any = null;
306
+ try {
307
+ browser = await chromium.launch({
308
+ headless: true,
309
+ ...(executablePath ? { executablePath } : {}),
310
+ ...(proxy ? { proxy } : {}),
311
+ args: ['--no-sandbox', '--disable-dev-shm-usage'],
312
+ });
313
+ const page = await browser.newPage();
314
+ await page.goto(appUrl, { waitUntil: 'networkidle', timeout: 30_000 }).catch(() => { });
315
+ await page.waitForTimeout(2_500); // let SPA / Web Components hydrate before scraping links
316
+ const hrefs: string[] = await page.evaluate((origin: string) => {
317
+ const found = new Set<string>();
318
+ const walk = (root: Document | ShadowRoot | Element) => {
319
+ (root as any).querySelectorAll('a[href]').forEach((a: any) => {
320
+ const h: string = a.href || a.getAttribute('href') || '';
321
+ if (h && (h.startsWith(origin) || h.startsWith('/'))) found.add(h);
322
+ });
323
+ (root as any).querySelectorAll('*').forEach((el: any) => {
324
+ if (el.shadowRoot) walk(el.shadowRoot);
325
+ });
326
+ };
327
+ walk(document);
328
+ return [...found];
329
+ }, new URL(appUrl).origin).catch(() => [] as string[]);
330
+ return [...new Set(hrefs.filter((h) => sameSite(h, appUrl)))].slice(0, opts?.maxLinks ?? 500);
331
+ } catch {
332
+ return [];
333
+ } finally {
334
+ if (browser) await browser.close().catch(() => { });
335
+ }
336
+ }
337
+
338
+ /**
339
+ * Run a Lighthouse audit against a URL and return category scores (0-100 integers).
340
+ * Requires ENABLE_LIGHTHOUSE=true env var — disabled by default to avoid slowing crawls.
341
+ * Returns null on any error or when disabled.
342
+ */
343
+ async function runLighthouse(url: string): Promise<{
344
+ perf: number; a11y: number; seo: number; bp: number; report: object;
345
+ } | null> {
346
+ if (!process.env.ENABLE_LIGHTHOUSE) return null;
347
+ try {
348
+ const { default: lighthouse } = await import('lighthouse');
349
+ const chromeLauncher = await import('chrome-launcher');
350
+ const chrome = await chromeLauncher.launch({
351
+ chromeFlags: ['--headless', '--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'],
352
+ });
353
+ try {
354
+ const result = await lighthouse(url, {
355
+ port: chrome.port,
356
+ output: 'json',
357
+ logLevel: 'error',
358
+ onlyCategories: ['performance', 'accessibility', 'seo', 'best-practices'],
359
+ } as any);
360
+ if (!result?.lhr?.categories) return null;
361
+ const cats = result.lhr.categories as Record<string, { score: number | null }>;
362
+ const score = (key: string) => Math.round((cats[key]?.score ?? 0) * 100);
363
+ return {
364
+ perf: score('performance'),
365
+ a11y: score('accessibility'),
366
+ seo: score('seo'),
367
+ bp: score('best-practices'),
368
+ report: result.lhr as object,
369
+ };
370
+ } finally {
371
+ await chrome.kill();
372
+ }
373
+ } catch (err) {
374
+ console.warn('[crawler] Lighthouse run failed (non-fatal):', err);
375
+ return null;
376
+ }
377
+ }
378
+
379
+ const ElementSchema = z.object({
380
+ elements: z.array(z.object({
381
+ meaning: z.string(),
382
+ role: z.string(),
383
+ expectedData: z.string().optional(),
384
+ })),
385
+ });
386
+
387
+ const LinksSchema = z.object({
388
+ links: z.array(z.string()),
389
+ });
390
+
391
+ const TwoFASchema = z.object({
392
+ has2FA: z.boolean(),
393
+ fieldLabel: z.string().optional(),
394
+ });
395
+
396
+ export async function crawlProject(opts: {
397
+ tenantId: string;
398
+ projectId: string;
399
+ appUrl: string;
400
+ credentials?: Credentials;
401
+ /** Playwright storageState JSON string — if present, session is restored and AI login is skipped. */
402
+ storageState?: string;
403
+ screenshotDir?: string;
404
+ /** When true, run a fast URL-only discovery crawl — skips screenshots, DB saves, and AI element extraction. Returns discoveredUrls in the result object. */
405
+ discoveryMode?: boolean;
406
+ maxScreens?: number;
407
+ /** Seed URLs added to the initial crawl queue — link discovery still runs from each page. */
408
+ startUrls?: string[];
409
+ /** When provided, crawl ONLY these exact URLs (no link-following, no full screen clear). Used for selected-screen recrawl. */
410
+ selectiveUrls?: string[];
411
+ /** When true, run the interactive probe after static crawl to capture real validation errors, toasts, and modals. */
412
+ interactive?: boolean;
413
+ /** Optional abort signal — abort() cancels the crawl between page navigations. */
414
+ abortSignal?: AbortSignal;
415
+ /** Platform job ID — stored on screens so we can diff which were found in each crawl. */
416
+ jobId?: string;
417
+ /** Device profiles to capture screenshots for after the main DESKTOP crawl.
418
+ * Values: 'MOBILE_PORTRAIT' | 'TABLET'
419
+ * The main crawl always runs as DESKTOP. Extra profiles trigger a screenshot-only pass.
420
+ */
421
+ deviceProfiles?: string[];
422
+ /** HTTP Basic Auth credentials — injected as Authorization header on same-origin requests. */
423
+ httpBasicUsername?: string;
424
+ httpBasicPassword?: string;
425
+ /** Bearer/API token — injected as a request header and into localStorage. Skips AI login. */
426
+ authToken?: string;
427
+ /** Header name for authToken (default: 'Authorization'). */
428
+ authTokenHeader?: string;
429
+ /** Prefix for authToken value (default: 'Bearer '). */
430
+ authTokenPrefix?: string;
431
+ /** CAPTCHA solving API key (2captcha or CapSolver). */
432
+ captchaSolverApiKey?: string;
433
+ /** CAPTCHA solving service provider (default: '2captcha'). */
434
+ captchaSolverProvider?: '2captcha' | 'capsolver';
435
+ /**
436
+ * Path to a Chrome user data directory for profile inheritance.
437
+ * When set, the crawler exports the existing session from this profile and injects it,
438
+ * so the agent is already logged in. Works for local/self-hosted deployments only.
439
+ * Ignored on cloud/Docker (headless env cannot open another Chrome instance safely).
440
+ */
441
+ chromeProfilePath?: string;
442
+ /** MailSlurp API key for email OTP auto-retrieval (GAP 5). */
443
+ mailslurpApiKey?: string;
444
+ /** MailSlurp inbox ID to poll for OTP emails (GAP 5). */
445
+ mailslurpInboxId?: string;
446
+ /** SSO provider for ROPC token flow — 'okta' | 'azure_ad' | 'generic_oidc' (GAP 18). */
447
+ ssoProvider?: string;
448
+ /** SSO domain or Azure tenant ID (GAP 18). */
449
+ ssoDomain?: string;
450
+ /** SSO OAuth2 client ID (GAP 18). */
451
+ ssoClientId?: string;
452
+ /** SSO OAuth2 client secret (GAP 18). */
453
+ ssoClientSecret?: string;
454
+ /** SSO scopes (space-separated, default: 'openid profile') (GAP 18). */
455
+ ssoScope?: string;
456
+ /** HTTP/HTTPS/SOCKS5 proxy URL — e.g. 'http://user:pass@proxy.example.com:8080' or 'socks5://proxy:1080'.
457
+ * Useful for sites that block datacenter IPs (Akamai, Cloudflare Bot Manager). */
458
+ proxyUrl?: string;
459
+ /** Tiered proxy configuration for automatic per-domain escalation (free → datacenter → residential → premium). */
460
+ proxyTiers?: ProxyTierConfig[];
461
+ /** Use Camoufox (Firefox) as a cookie bootstrapper before the main Chromium crawl.
462
+ * Camoufox visits the URL first to solve Cloudflare JS challenges and extract session
463
+ * cookies, which are then injected into the Chromium crawl as storageState.
464
+ * Enable via Project Settings → Crawl Config → useFirefox: true. */
465
+ useFirefox?: boolean;
466
+ /** Natural language login instructions for Stagehand AI login. E.g. "Click Sign In in the nav, fill email, click Continue". */
467
+ loginInstructions?: string;
468
+ /** Skyvern vision fallback config from workspace settings (overrides SKYVERN_API_URL/KEY env vars). */
469
+ skyvernConfig?: SkyvernConfig;
470
+ /**
471
+ * Max screens to crawl per segment (default 50).
472
+ * After this limit the crawl pauses and remaining URLs are saved so the
473
+ * next job can continue. Total crawl is unlimited — just runs in multiple jobs.
474
+ */
475
+ segmentSize?: number;
476
+ /** URLs still queued from a previous segment — injected by the job-queue auto-continuation. */
477
+ resumeUrls?: string[];
478
+ /** Segment number (1-based) — used for progress display only. */
479
+ segmentIndex?: number;
480
+ /** Seed the crawl queue from the project's sitemap.xml, in addition to link-following. */
481
+ useSitemap?: boolean;
482
+ /** Explicit sitemap URL override — defaults to `${origin}/sitemap.xml` when useSitemap is set. */
483
+ sitemapUrl?: string;
484
+ /** Crawl queue traversal order. Default BFS — unchanged behavior from before this option existed. */
485
+ traversalStrategy?: TraversalStrategy;
486
+ /** BEST_FIRST only: URLs containing any of these (case-insensitive) are prioritized. */
487
+ bestFirstKeywords?: string[];
488
+ /** When true, skip URLs disallowed by the site's robots.txt. Default false. */
489
+ respectRobotsTxt?: boolean;
490
+ /** When true, dynamically slow crawl speed when server response latency is high (Scrapy-style AutoThrottle). Default false. */
491
+ autoThrottle?: boolean;
492
+ /** Regex pattern — only crawl URLs whose path+query match this pattern. */
493
+ urlFilter?: string;
494
+ /** Already-captured URLs from a previous failed job — pre-seeded into visited map so they're never re-crawled. */
495
+ skipUrls?: string[];
496
+ /** DOM hashes collected in a prior segment — prevents re-screenshotting structurally identical pages across segments. */
497
+ knownDomHashes?: string[];
498
+ /** Called every 10 successful screen captures with current visited URLs and remaining queue — used for checkpoint persistence. */
499
+ onCheckpoint?: (visitedUrls: string[], pendingQueue: string[]) => Promise<void>;
500
+ /** Discovery mode only: called after each page with the current set of discovered original URLs — enables streaming partial results to callers. */
501
+ onUrlsDiscovered?: (urls: string[]) => Promise<void>;
502
+ }, onProgress?: (pct: number, step: string) => Promise<void>) {
503
+ if (opts.screenshotDir) await fs.mkdir(opts.screenshotDir, { recursive: true });
504
+
505
+ const isDocker = !!process.env.PLAYWRIGHT_IN_DOCKER || process.env.NODE_ENV === 'production';
506
+
507
+ // Use tenant LLM config if available; fall back to env vars (Anthropic only for Stagehand).
508
+ // Stagehand v3 requires model in "provider/model" format (e.g. "anthropic/claude-sonnet-4-6").
509
+ const toStagehandModel = (model: string, provider: string) =>
510
+ model.includes('/') ? model : `${provider}/${model}`;
511
+
512
+ let stagehandModel = toStagehandModel(config.llmModel, 'openai');
513
+ let stagehandApiKey = '';
514
+ let configuredProvider = '';
515
+ try {
516
+ // getTaskAgentLLMConfig: tenant config → platform admin config → null (proper fallback chain)
517
+ const crawlLLM = await getTaskAgentLLMConfig(opts.tenantId, 'crawl');
518
+ if (crawlLLM) {
519
+ configuredProvider = crawlLLM.provider;
520
+ if (crawlLLM.apiKey && crawlLLM.model) {
521
+ if (crawlLLM.provider === 'anthropic' || crawlLLM.provider === 'openai') {
522
+ stagehandModel = toStagehandModel(crawlLLM.model, crawlLLM.provider);
523
+ stagehandApiKey = crawlLLM.apiKey;
524
+ } else if (crawlLLM.provider === 'openrouter') {
525
+ const model = crawlLLM.model.includes('/') ? crawlLLM.model : `openai/${crawlLLM.model}`;
526
+ stagehandModel = model;
527
+ stagehandApiKey = crawlLLM.apiKey;
528
+ }
529
+ }
530
+ }
531
+ } catch { /* ignore DB errors, fall through to env var below */ }
532
+ // Env var fallback — if platform/tenant config lookup failed or returned no key
533
+ if (!stagehandApiKey) {
534
+ // Honor STAGEHAND_MODEL env var to determine preferred provider.
535
+ // Stagehand v3 requires "provider/model" format — mismatched pair silently fails.
536
+ const envModel = process.env.STAGEHAND_MODEL ?? '';
537
+ const preferAnthropic = envModel.includes('claude') || (!envModel && !process.env.OPENAI_API_KEY);
538
+ if (preferAnthropic && process.env.ANTHROPIC_API_KEY) {
539
+ stagehandApiKey = process.env.ANTHROPIC_API_KEY;
540
+ stagehandModel = envModel.includes('/') ? envModel : `anthropic/${envModel || 'claude-sonnet-4-6'}`;
541
+ } else if (process.env.OPENAI_API_KEY) {
542
+ stagehandApiKey = process.env.OPENAI_API_KEY;
543
+ stagehandModel = envModel.includes('/') ? envModel : `openai/${envModel || 'gpt-4o'}`;
544
+ } else if (process.env.ANTHROPIC_API_KEY) {
545
+ stagehandApiKey = process.env.ANTHROPIC_API_KEY;
546
+ stagehandModel = envModel.includes('/') ? envModel : `anthropic/${envModel || 'claude-sonnet-4-6'}`;
547
+ }
548
+ }
549
+
550
+ const elementLLM = await AppBrain.getTaskLLMConfig(opts.tenantId, 'elementExtraction').catch(() => null);
551
+ const linkLLM = await AppBrain.getTaskLLMConfig(opts.tenantId, 'linkDiscovery').catch(() => null);
552
+ const projectForProfile = await AppBrain.getProjectMap(opts.tenantId, opts.projectId).catch(() => null);
553
+ const profileProjectName = projectForProfile?.name ?? null;
554
+ const profileAppType = projectForProfile?.appType ?? null;
555
+ const domainProfile = classifyDomainProfile({
556
+ appUrl: opts.appUrl,
557
+ projectName: profileProjectName,
558
+ appType: profileAppType,
559
+ seedText: [
560
+ ...(opts.startUrls ?? []),
561
+ opts.loginInstructions ?? '',
562
+ ...(opts.bestFirstKeywords ?? []),
563
+ ].join(' '),
564
+ });
565
+ const routeDecision = chooseCrawlEngines({
566
+ appUrl: opts.appUrl,
567
+ appType: profileAppType,
568
+ hasCredentials: !!opts.credentials,
569
+ hasStorageState: !!opts.storageState,
570
+ hasAuthToken: !!opts.authToken,
571
+ hasSkyvern: !!opts.skyvernConfig,
572
+ hasProxy: !!opts.proxyUrl,
573
+ profile: domainProfile,
574
+ });
575
+
576
+ AppBrain.saveCrawlEvidence(opts.tenantId, opts.projectId, {
577
+ jobId: opts.jobId,
578
+ type: 'CRAWL_PROFILE',
579
+ payload: { profile: domainProfile, routeDecision },
580
+ }).catch((err: any) => console.warn('[crawler] profile evidence save failed:', err?.message ?? err));
581
+
582
+ if (!stagehandApiKey) {
583
+ const unsupported = configuredProvider && !['anthropic', 'openai', 'openrouter'].includes(configuredProvider);
584
+ throw new Error(
585
+ unsupported
586
+ ? `Browser crawling requires Anthropic or OpenAI. Your primary provider "${configuredProvider}" is not supported for crawling. Add a separate Crawl API Key in Workspace → Settings → AI Provider.`
587
+ : 'No AI provider configured. Go to Workspace → Settings → AI Provider to set up Anthropic or OpenAI for browser crawling.'
588
+ );
589
+ }
590
+
591
+ // Anti-detect args applied on all platforms.
592
+ // '--disable-blink-features=AutomationControlled' removes navigator.webdriver at the
593
+ // Chromium level — more reliable than JS overrides alone.
594
+ // '--disable-http2' defeats Akamai/Cloudflare JA3/JA4 TLS fingerprinting on datacenter IPs.
595
+ const stealthArgs = [
596
+ '--disable-blink-features=AutomationControlled',
597
+ '--disable-http2',
598
+ '--lang=en-US,en',
599
+ '--window-size=1366,768',
600
+ '--disable-features=IsolateOrigins,site-per-process',
601
+ ];
602
+
603
+ const chromeArgs = isDocker
604
+ ? [
605
+ '--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-gpu',
606
+ ...stealthArgs,
607
+ ]
608
+ : [...stealthArgs];
609
+ // Resolve proxy: tiered manager > 'auto' free list > explicit URL
610
+ let resolvedProxyUrl = opts.proxyUrl;
611
+ let tieredProxyManager: TieredProxyManager | undefined;
612
+ if (opts.proxyTiers?.length) {
613
+ tieredProxyManager = new TieredProxyManager(opts.proxyTiers);
614
+ const domain = (() => { try { return new URL(opts.appUrl).hostname; } catch { return opts.appUrl; } })();
615
+ resolvedProxyUrl = tieredProxyManager.selectProxy(domain) ?? resolvedProxyUrl;
616
+ logger.info({ domain, proxyUrl: resolvedProxyUrl }, '[crawler] tiered proxy selected');
617
+ } else if (resolvedProxyUrl === 'auto') {
618
+ logger.info('[crawler] proxyUrl=auto: selecting free proxy from public list...');
619
+ resolvedProxyUrl = (await pickFreeProxy()) ?? undefined;
620
+ if (resolvedProxyUrl) {
621
+ logger.info({ proxyUrl: resolvedProxyUrl }, '[crawler] auto-proxy selected');
622
+ } else {
623
+ logger.warn('[crawler] auto-proxy: no working proxy found, proceeding without proxy');
624
+ }
625
+ }
626
+ // Parse proxy URL into Playwright's native proxy format.
627
+ // We do NOT use --proxy-server Chrome arg because:
628
+ // 1. Chrome ignores credentials in --proxy-server URLs (no auth support via flag)
629
+ // 2. Commas in username (DataImpulse country codes) break Chrome's proxy-list parser
630
+ // Playwright's launchServer({ proxy }) handles 407 auth challenges correctly.
631
+ let playwrightProxy: { server: string; username?: string; password?: string } | undefined;
632
+ if (resolvedProxyUrl) {
633
+ try {
634
+ // Manual parse: new URL() chokes on commas in the username part
635
+ const proxyMatch = resolvedProxyUrl.match(/^(https?|socks5?):\/\/(?:([^:@]*)(?::([^@]*))?@)?(.+)$/);
636
+ if (proxyMatch) {
637
+ const [, scheme, rawUser, rawPass, hostPort] = proxyMatch;
638
+ const server = `${scheme}://${hostPort}`;
639
+ const username = rawUser ? decodeURIComponent(rawUser) : undefined;
640
+ const password = rawPass ? decodeURIComponent(rawPass) : undefined;
641
+ playwrightProxy = username ? { server, username, password } : { server };
642
+ logger.info({ server, hasAuth: !!username }, '[crawler] proxy configured (Playwright native)');
643
+ } else {
644
+ playwrightProxy = { server: resolvedProxyUrl };
645
+ }
646
+ } catch {
647
+ playwrightProxy = { server: resolvedProxyUrl };
648
+ }
649
+ }
650
+
651
+ // Stagehand 3.x uses chrome-launcher under the hood, which fails in Docker
652
+ // because it doesn't pass --no-sandbox and uses a different spawn environment.
653
+ // Fix: spawn Chrome ourselves with the right flags, get its CDP WS URL,
654
+ // then connect Stagehand to the already-running browser via cdpUrl.
655
+ let executablePath: string | undefined;
656
+ try { executablePath = chromium.executablePath(); } catch { /* use CHROME_PATH */ }
657
+
658
+ if (!executablePath) throw new Error('No Chrome binary found. Set CHROME_PATH or install Playwright browsers.');
659
+
660
+ // GAP 3: Chrome Profile Inheritance — local/self-hosted deployments only.
661
+ // Launch a temporary persistent context to export the storageState from the user's Chrome profile,
662
+ // then inject it so the normal session-restore path handles auth (no AI login needed).
663
+ let effectiveStorageState = opts.storageState;
664
+ if (opts.chromeProfilePath && !isDocker) {
665
+ logger.info({ chromeProfilePath: opts.chromeProfilePath }, '[crawler] Exporting session from Chrome profile');
666
+ try {
667
+ const tempCtx = await chromium.launchPersistentContext(opts.chromeProfilePath, {
668
+ headless: true,
669
+ args: [...chromeArgs, '--no-first-run', '--no-default-browser-check'],
670
+ executablePath,
671
+ });
672
+ const exportedState = await tempCtx.storageState();
673
+ await tempCtx.close().catch(() => { });
674
+ if ((exportedState.cookies?.length ?? 0) > 0 || (exportedState.origins?.length ?? 0) > 0) {
675
+ effectiveStorageState = JSON.stringify(exportedState);
676
+ logger.info({ cookies: exportedState.cookies?.length ?? 0, origins: exportedState.origins?.length ?? 0 }, '[crawler] Chrome profile session exported and injected');
677
+ } else {
678
+ logger.warn('[crawler] Chrome profile had no session data — continuing without profile auth');
679
+ }
680
+ } catch (profileErr) {
681
+ logger.warn({ err: profileErr }, '[crawler] Could not load Chrome profile — continuing without profile auth');
682
+ }
683
+ } else if (opts.chromeProfilePath && isDocker) {
684
+ logger.warn('[crawler] chromeProfilePath is set but will be ignored in Docker/cloud environments');
685
+ }
686
+
687
+ // ── Option 3: Camoufox bootstrap ─────────────────────────────────────────────
688
+ // Firefox with built-in fingerprint randomisation visits the target first to solve
689
+ // any Cloudflare JS challenge, then we extract the cf_clearance cookie and inject it
690
+ // into the Chromium storageState so Chrome inherits the cleared session.
691
+ if (opts.useFirefox) {
692
+ logger.info('[crawler] useFirefox: launching Camoufox to bootstrap Cloudflare cookies...');
693
+ try {
694
+ // Dynamic import prevents ESM/CJS conflict at API startup when this module is imported
695
+ const { NewBrowser: camoufoxNewBrowser } = await import('camoufox');
696
+ const pw = require('playwright');
697
+ const foxBrowser = await camoufoxNewBrowser(pw, true);
698
+ const foxPage = await foxBrowser.newPage();
699
+ await foxPage.goto(opts.appUrl, { waitUntil: 'domcontentloaded', timeout: 30_000 });
700
+ // Give Cloudflare JS challenge up to 8s to complete and set cf_clearance cookie
701
+ await foxPage.waitForTimeout(8_000);
702
+ const foxState = await foxPage.context().storageState();
703
+ await foxBrowser.close().catch(() => { });
704
+ if ((foxState.cookies?.length ?? 0) > 0) {
705
+ // Merge Camoufox cookies into any existing storageState
706
+ if (effectiveStorageState) {
707
+ try {
708
+ const existing = JSON.parse(effectiveStorageState);
709
+ const merged = { ...existing, cookies: [...(existing.cookies ?? []), ...foxState.cookies] };
710
+ effectiveStorageState = JSON.stringify(merged);
711
+ } catch {
712
+ effectiveStorageState = JSON.stringify(foxState);
713
+ }
714
+ } else {
715
+ effectiveStorageState = JSON.stringify(foxState);
716
+ }
717
+ logger.info({ cookies: foxState.cookies.length }, '[crawler] Camoufox bootstrap complete — cf_clearance injected into Chromium');
718
+ } else {
719
+ logger.warn('[crawler] Camoufox returned no cookies — continuing without bootstrap');
720
+ }
721
+ } catch (foxErr) {
722
+ logger.warn({ err: foxErr }, '[crawler] Camoufox bootstrap failed — continuing without it');
723
+ }
724
+ }
725
+
726
+ const { wsUrl: cdpUrl, proc: chromeProc } = await launchChromeCDP(executablePath, chromeArgs, playwrightProxy);
727
+
728
+ // Accumulate token usage from Stagehand's internal LLM calls (extract/act/observe).
729
+ // Stagehand doesn't expose per-call usage externally; we parse it from its logger.
730
+ let stagehandPromptTokens = 0;
731
+ let stagehandCompletionTokens = 0;
732
+ const stagehandLogger = (line: { category?: string; message: string; auxiliary?: Record<string, { value: string; type: string }> }) => {
733
+ if (line.message === 'response' && line.auxiliary?.response?.value) {
734
+ try {
735
+ const resp = JSON.parse(line.auxiliary.response.value);
736
+ if (resp?.usage) {
737
+ stagehandPromptTokens += resp.usage.prompt_tokens ?? 0;
738
+ stagehandCompletionTokens += resp.usage.completion_tokens ?? 0;
739
+ }
740
+ } catch { /* ignore parse errors */ }
741
+ }
742
+ };
743
+
744
+ const stagehand = new Stagehand({
745
+ env: 'LOCAL',
746
+ model: { modelName: stagehandModel as any, apiKey: stagehandApiKey },
747
+ serverCache: true,
748
+ verbose: 1,
749
+ logger: stagehandLogger,
750
+ localBrowserLaunchOptions: { cdpUrl, headless: true },
751
+ domSettleTimeout: 2000,
752
+ });
753
+
754
+ await stagehand.init();
755
+ await onProgress?.(5, 'Browser launched');
756
+ // In Stagehand v3, stagehand.page is undefined when connected via CDP.
757
+ // Use resolvePage() to get the active V3Page (which is a full Playwright proxy).
758
+ const page = await (stagehand as any).resolvePage();
759
+ // Cap all Playwright operations so one stuck page can't freeze the crawl.
760
+ try { page.setDefaultTimeout(45_000); } catch { /* proxy may not expose this */ }
761
+ try { page.setDefaultNavigationTimeout(30_000); } catch { /* proxy may not expose this */ }
762
+ // Stagehand V3Page proxy only supports the 'console' event (page.on throws for others).
763
+ // rawPage will be wired to a real Playwright Page via the CDP connection established below.
764
+ // Declared as `let` so the network-observer block can populate it.
765
+ let rawPage: any = null; // populated after playwrightBrowser connects
766
+
767
+ // ── Proxy auth ───────────────────────────────────────────────────────────────
768
+ // Proxy 407 auth is answered via a CDP Fetch.continueWithAuth handler installed on
769
+ // the connectOverCDP page below (see "proxy auth via CDP Fetch handler"). page.authenticate()
770
+ // is a Puppeteer API — it does NOT exist on Playwright's connectOverCDP page and throws,
771
+ // leaving 407 challenges unanswered (net::ERR_INVALID_AUTH_CREDENTIALS).
772
+
773
+ // ── Stealth: defeat bot detection (Cloudflare, Akamai, DataDome) ────────────
774
+ // addInitScript runs in every new document context BEFORE any page JS executes.
775
+ // This overrides navigator.webdriver before anti-bot scripts can read it.
776
+ await page.addInitScript(() => {
777
+ // Core automation flags
778
+ Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
779
+ Object.defineProperty(navigator, 'languages', { get: () => ['en-US', 'en'] });
780
+
781
+ // Realistic plugin list (Cloudflare checks length > 0)
782
+ const fakePlugins = ['PDF Viewer', 'Chrome PDF Viewer', 'Chromium PDF Viewer', 'Microsoft Edge PDF Viewer', 'WebKit built-in PDF'];
783
+ Object.defineProperty(navigator, 'plugins', {
784
+ get: () => Object.assign(fakePlugins.map((name, i) => ({ name, filename: 'internal-pdf-viewer', description: '', length: 1, item: () => null, namedItem: () => null })), { item: (i: number) => null, namedItem: () => null, refresh: () => { } }),
785
+ });
786
+
787
+ // @ts-ignore
788
+ window.chrome = { runtime: {}, loadTimes: () => ({}), csi: () => ({}), app: {} };
789
+
790
+ // Cloudflare checks navigator.permissions.query — headless returns "denied", real = "prompt"
791
+ const origQuery = window.navigator.permissions?.query?.bind(navigator.permissions);
792
+ if (origQuery) {
793
+ // @ts-ignore
794
+ navigator.permissions.query = (params: any) =>
795
+ params?.name === 'notifications'
796
+ ? Promise.resolve({ state: 'prompt', onchange: null } as PermissionStatus)
797
+ : origQuery(params);
798
+ }
799
+
800
+ // Cloudflare checks Notification.permission — "denied" = bot signal
801
+ try { Object.defineProperty(Notification, 'permission', { get: () => 'default' }); } catch { }
802
+
803
+ // navigator.connection — absence signals headless
804
+ // @ts-ignore
805
+ if (!navigator.connection) Object.defineProperty(navigator, 'connection', { get: () => ({ rtt: 50, downlink: 10, effectiveType: '4g', saveData: false }) });
806
+
807
+ // Remove Playwright/CDP globals leaked into page context
808
+ // @ts-ignore
809
+ delete window.__playwright; delete (window as any).__pw_manual; delete (window as any).__PW_inspect;
810
+ });
811
+
812
+ // ── Web Vitals capture: LCP + CLS ────────────────────────────────────────────
813
+ // LCP/CLS entries are only delivered to a PerformanceObserver registered with
814
+ // buffered:true — performance.getEntriesByType(...) after the fact returns [].
815
+ // addInitScript re-runs on every new document (survives page.goto navigations),
816
+ // so the observers are always registered before the page paints. The crawl loop
817
+ // reads the stashed values off window.__zetaLCP / window.__zetaCLS after load.
818
+ await page.addInitScript(() => {
819
+ try {
820
+ // @ts-ignore — custom stash properties
821
+ window.__zetaLCP = null;
822
+ // @ts-ignore
823
+ window.__zetaCLS = 0;
824
+ new PerformanceObserver((list) => {
825
+ const entries = list.getEntries();
826
+ const last = entries[entries.length - 1] as any;
827
+ // @ts-ignore
828
+ if (last) window.__zetaLCP = last.startTime;
829
+ }).observe({ type: 'largest-contentful-paint', buffered: true });
830
+ new PerformanceObserver((list) => {
831
+ for (const entry of list.getEntries() as any[]) {
832
+ // Ignore shifts triggered by user input (crawler clicks/scrolls).
833
+ // @ts-ignore
834
+ if (!entry.hadRecentInput) window.__zetaCLS += entry.value;
835
+ }
836
+ }).observe({ type: 'layout-shift', buffered: true });
837
+ } catch { /* older engines without these entry types — leave stashed values at defaults */ }
838
+ });
839
+
840
+ // ── HTTP Basic Auth: inject Authorization header for same-origin requests ───
841
+ // CDP-connected contexts cannot use browser.newContext({ httpCredentials }),
842
+ // so we intercept via page.route() instead.
843
+ if (opts.httpBasicUsername && opts.httpBasicPassword) {
844
+ const encoded = Buffer.from(`${opts.httpBasicUsername}:${opts.httpBasicPassword}`).toString('base64');
845
+ const appOrigin = new URL(opts.appUrl).origin;
846
+ await page.route('**/*', (route: any) => {
847
+ const reqUrl = route.request().url();
848
+ if (reqUrl.startsWith(appOrigin)) {
849
+ route.continue({
850
+ headers: { ...route.request().headers(), 'Authorization': `Basic ${encoded}` },
851
+ });
852
+ } else {
853
+ route.continue();
854
+ }
855
+ });
856
+ }
857
+
858
+ // ── API/Bearer token injection ────────────────────────────────────────────
859
+ // If an authToken is provided, inject it as an HTTP header on every request
860
+ // and store it in localStorage so client-side code can pick it up.
861
+ if (opts.authToken) {
862
+ const headerName = opts.authTokenHeader ?? 'Authorization';
863
+ const prefix = opts.authTokenPrefix ?? 'Bearer ';
864
+ await page.setExtraHTTPHeaders({ [headerName]: `${prefix}${opts.authToken}` });
865
+ await page.addInitScript((token: string) => {
866
+ localStorage.setItem('auth_token', token);
867
+ localStorage.setItem('access_token', token);
868
+ localStorage.setItem('token', token);
869
+ }, opts.authToken);
870
+ }
871
+
872
+ // ── Web Vitals capture: CLS via buffered PerformanceObserver ─────────────────
873
+ // CLS entries are only delivered to a PerformanceObserver; this init script
874
+ // re-runs on every new document. LCP is captured per-page in the crawl loop.
875
+ await page.addInitScript(() => {
876
+ try {
877
+ // @ts-ignore — custom stash property
878
+ window.__zetaCLS = 0;
879
+ new PerformanceObserver((list) => {
880
+ for (const entry of list.getEntries() as any[]) {
881
+ // Ignore shifts triggered by user input (crawler clicks/scrolls).
882
+ // @ts-ignore
883
+ if (!entry.hadRecentInput) window.__zetaCLS += entry.value;
884
+ }
885
+ }).observe({ type: 'layout-shift', buffered: true });
886
+ } catch { /* Older engines without layout-shift entry type — leave at 0 */ }
887
+ });
888
+
889
+ // Intercept SPA route changes (history.pushState / replaceState) to discover JS-router routes
890
+ // that have no <a href> elements — common in React Router, Next.js, Vue Router apps.
891
+ await page.addInitScript(() => {
892
+ (window as any).__zetaDiscoveredRoutes = (window as any).__zetaDiscoveredRoutes ?? [];
893
+ const push = history.pushState.bind(history);
894
+ const replace = history.replaceState.bind(history);
895
+ history.pushState = function (data: any, unused: string, url?: string | URL | null) {
896
+ const result = push(data, unused, url);
897
+ if (url && typeof url === 'string') (window as any).__zetaDiscoveredRoutes.push(url);
898
+ return result;
899
+ };
900
+ history.replaceState = function (data: any, unused: string, url?: string | URL | null) {
901
+ const result = replace(data, unused, url);
902
+ if (url && typeof url === 'string') (window as any).__zetaDiscoveredRoutes.push(url);
903
+ return result;
904
+ };
905
+ });
906
+
907
+ // Inject canvas/WebGL fingerprint overrides + hardware concurrency spoof
908
+ await injectFingerprintOverrides(page).catch(() => { });
909
+
910
+ // ── Network interception: capture REST/GraphQL API endpoints ────────────────
911
+ // Stagehand v3's page proxy only supports the 'console' event — to intercept
912
+ // network requests we connect a second Playwright client to the same CDP URL.
913
+ const capturedApiEndpoints: ApiEndpointObservationInput[] = [];
914
+ // P3a: URLs flagged for re-visit after async content detected (drained after main BFS)
915
+ const revisitQueue: string[] = [];
916
+ // P4: XHR-discovered page URL candidates (relative paths from JSON responses)
917
+ const xhrDiscoveredPaths = new Set<string>();
918
+
919
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
920
+ let playwrightBrowser: any = null;
921
+ try {
922
+ playwrightBrowser = await chromium.connectOverCDP(cdpUrl);
923
+ const pwContext = playwrightBrowser.contexts()[0];
924
+ if (pwContext) {
925
+ // P4: pass XHR URL discovery callback — adds relative paths to xhrDiscoveredPaths
926
+ attachNetworkObserver(pwContext, capturedApiEndpoints, (relPath) => xhrDiscoveredPaths.add(relPath));
927
+ // OAuth/SSO popup handler — attach before login so we don't miss it
928
+ pwContext.on('page', (popup: any) => {
929
+ const popupUrl = popup.url();
930
+ logger.info({ url: popupUrl }, '[crawler] OAuth popup detected — waiting for close');
931
+ popup.waitForEvent('close', { timeout: 120_000 })
932
+ .then(() => logger.info('[crawler] OAuth popup closed — SSO flow complete'))
933
+ .catch(() => logger.warn('[crawler] OAuth popup timed out or errored'));
934
+ });
935
+ }
936
+ // Wire rawPage to the real Playwright page from this CDP connection.
937
+ // This gives us a true Playwright Page that supports all events (.on('response'), etc.)
938
+ // while `page` (Stagehand) is still used for AI actions (act/extract/observe).
939
+ const pwContext2 = playwrightBrowser?.contexts()?.[0];
940
+ if (pwContext2) {
941
+ const pwPages = pwContext2.pages();
942
+ rawPage = pwPages.find((p: any) => p.url() !== 'about:blank') ?? pwPages[0] ?? rawPage;
943
+ }
944
+
945
+ // ── Proxy auth via CDP Fetch handler ──────────────────────────────────────
946
+ // launchServer({ proxy }) applies the proxy at the Chrome flag level, but its
947
+ // credentials are only honored by Playwright's OWN launching client. Since we
948
+ // drive the browser through a separate connectOverCDP client, the proxy 407 goes
949
+ // unanswered (net::ERR_INVALID_AUTH_CREDENTIALS). Answer it via the CDP Fetch domain,
950
+ // then disable interception — Chrome caches the proxy credentials for the rest of the session.
951
+ if (playwrightProxy?.username && pwContext2 && rawPage && rawPage !== page) {
952
+ try {
953
+ const proxyCdp = await pwContext2.newCDPSession(rawPage);
954
+ await proxyCdp.send('Fetch.enable', { handleAuthRequests: true, patterns: [{ urlPattern: '*' }] });
955
+ proxyCdp.on('Fetch.requestPaused', (e: any) => {
956
+ proxyCdp.send('Fetch.continueRequest', { requestId: e.requestId }).catch(() => { });
957
+ });
958
+ let proxyAuthed = false;
959
+ proxyCdp.on('Fetch.authRequired', async (e: any) => {
960
+ const isProxy = e.authChallenge?.source === 'Proxy';
961
+ try {
962
+ await proxyCdp.send('Fetch.continueWithAuth', {
963
+ requestId: e.requestId,
964
+ authChallengeResponse: isProxy
965
+ ? { response: 'ProvideCredentials', username: playwrightProxy!.username as string, password: playwrightProxy!.password ?? '' }
966
+ : { response: 'Default' },
967
+ });
968
+ } catch { /* ignore */ }
969
+ if (isProxy && !proxyAuthed) {
970
+ proxyAuthed = true;
971
+ proxyCdp.send('Fetch.disable').catch(() => { });
972
+ }
973
+ });
974
+ logger.info({ server: playwrightProxy.server }, '[crawler] proxy auth via CDP Fetch handler enabled');
975
+ } catch (e: any) {
976
+ logger.warn({ err: e?.message }, '[crawler] CDP proxy-auth setup failed — 407 may not resolve');
977
+ }
978
+ }
979
+ } catch {
980
+ // Non-fatal — crawl proceeds without API endpoint capture
981
+ }
982
+ // Final fallback: if rawPage is still null, use the Stagehand page (events may not work)
983
+ if (!rawPage) rawPage = page;
984
+
985
+ try {
986
+ // Step 1: Navigate and authenticate
987
+ await onProgress?.(8, 'Loading app');
988
+ // Use explicit timeout — Stagehand V3Page proxy silently ignores setDefaultNavigationTimeout.
989
+ // Fall back to 'commit' (HTTP response received) if DOM takes too long; page is still usable.
990
+ await page.goto(opts.appUrl, { waitUntil: 'domcontentloaded', timeout: 30_000 }).catch(async () => {
991
+ await page.goto(opts.appUrl, { waitUntil: 'commit', timeout: 15_000 }).catch(() => { });
992
+ });
993
+ // Auto-accept cookie consent banners — common EU sites show consent walls before content.
994
+ await acceptCookieConsent(page, { waitMs: 1_000 }).catch(() => { });
995
+
996
+ // Fail fast if the app URL itself is unreachable — datacenter IP block, DNS failure, etc.
997
+ if (page.url().startsWith('chrome-error://')) {
998
+ throw new Error(`Cannot reach ${opts.appUrl} — the server refused the connection (likely IP block or DNS failure). Go to Project Settings → Connectivity → Proxy and select "Auto" or enter a residential proxy URL.`);
999
+ }
1000
+ // Fail fast if Cloudflare or similar bot-protection challenge is served instead of the real app.
1001
+ // These pages never contain testable UI — crawling them produces zero useful screens.
1002
+ const initialTitle = await page.title().catch(() => '');
1003
+ const isBotChallenge = /^(just a moment|attention required|access denied|ddos protection|cloudflare|checking your browser|enable javascript)/i.test(initialTitle);
1004
+ if (isBotChallenge) {
1005
+ throw new Error(`Cannot crawl ${opts.appUrl} — bot protection challenge detected (title: "${initialTitle}"). The site is blocking automated browsers. Go to Project Settings → Connectivity → Proxy and select "Auto" (free proxies) or enter a residential proxy URL to bypass.`);
1006
+ }
1007
+ // Wait for SPA client-side redirect to complete (e.g. Next.js useEffect router.replace to /login).
1008
+ // Without this, loginWithStagehand may run on an empty/loading root page before the form renders.
1009
+ await page.waitForLoadState('networkidle', { timeout: 10_000 }).catch(() => { });
1010
+ await waitForStableDOM(page);
1011
+
1012
+ // Dedicated logged-out/auth discovery phase. This runs before credentials or
1013
+ // storageState are applied, so public pages and auth recovery flows remain
1014
+ // visible even when the main crawl later becomes authenticated.
1015
+ if (!opts.discoveryMode) {
1016
+ try {
1017
+ await runAuthDiscoveryPhase(page, opts, new URL(opts.appUrl).origin, onProgress);
1018
+ } catch (preAuthErr) {
1019
+ logger.warn({ err: preAuthErr }, '[crawler] auth-discovery phase failed — continuing with authenticated crawl');
1020
+ }
1021
+ }
1022
+
1023
+ let didLogin = false; // track whether AI login was actually performed
1024
+
1025
+ if (opts.authToken) {
1026
+ // Auth token already injected via setExtraHTTPHeaders + addInitScript above.
1027
+ // Skip session restore and AI login entirely.
1028
+ await onProgress?.(18, 'API token configured — skipping login');
1029
+ logger.info('authToken provided — skipping AI login');
1030
+ } else if (effectiveStorageState) {
1031
+ // ── StorageState session restore: skip AI login entirely ─────────────────
1032
+ // User authenticated once via `npx playwright codegen --save-storage=session.json`
1033
+ // and saved the resulting JSON in crawl config (encrypted at rest).
1034
+ // effectiveStorageState may also come from Chrome profile inheritance (GAP 3).
1035
+ logger.info('StorageState found — restoring session, skipping AI login');
1036
+ try {
1037
+ const state = JSON.parse(effectiveStorageState!) as {
1038
+ cookies?: Array<Record<string, any>>;
1039
+ origins?: Array<{ origin: string; localStorage: Array<{ name: string; value: string }> }>;
1040
+ };
1041
+
1042
+ if (state.cookies?.length) {
1043
+ // page.context() is not available on Stagehand V3 proxy — use the secondary Playwright client
1044
+ const browserCtx = playwrightBrowser?.contexts()[0];
1045
+ if (browserCtx) {
1046
+ await browserCtx.addCookies(state.cookies as any);
1047
+ }
1048
+ }
1049
+
1050
+ if (state.origins?.length) {
1051
+ await rawPage.addInitScript((origins: typeof state.origins) => {
1052
+ for (const entry of (origins ?? [])) {
1053
+ if (window.location.origin === entry.origin) {
1054
+ for (const item of entry.localStorage) {
1055
+ try { localStorage.setItem(item.name, item.value); } catch { /* quota/sandboxed */ }
1056
+ }
1057
+ }
1058
+ }
1059
+ }, state.origins);
1060
+ }
1061
+
1062
+ // Reload to apply cookies + trigger localStorage restore initScript
1063
+ await onProgress?.(12, 'Restoring session');
1064
+ await rawPage.reload({ waitUntil: 'domcontentloaded' });
1065
+ await waitForStableDOM(page);
1066
+ // SPA root pages redirect client-side (useEffect → router.replace). Wait for it.
1067
+ if (page.url() === opts.appUrl || page.url() === opts.appUrl.replace(/\/$/, '')) {
1068
+ await waitForURLChange(page, opts.appUrl).catch(() => { });
1069
+ await waitForStableDOM(page);
1070
+ }
1071
+
1072
+ // Detect if session restore failed — either by URL redirect or login form presence on root URL
1073
+ const postRestoreUrl = page.url();
1074
+ const LOGIN_PATH = /\/(login|signin|sign-in|auth|log-in|sso|oauth|account\/login)(\/|$|\?)/i;
1075
+ const ERROR_PATH = /\/(session-expired|expired|unauthorized|forbidden|401|403|error|logout|logged-out)(\/|$|\?)/i;
1076
+ const redirectedToLogin = LOGIN_PATH.test(new URL(postRestoreUrl).pathname);
1077
+ const redirectedToError = ERROR_PATH.test(new URL(postRestoreUrl).pathname);
1078
+ // Also check for login form presence — catches apps where login page is at root URL (e.g. /)
1079
+ const hasLoginForm = await page.evaluate(() => {
1080
+ const inputs = Array.from(document.querySelectorAll('input'));
1081
+ const hasEmailOrUser = inputs.some((i: any) =>
1082
+ ['email', 'username', 'user', 'login'].some((k) =>
1083
+ (i.type || '').includes(k) || (i.name || '').toLowerCase().includes(k) ||
1084
+ (i.id || '').toLowerCase().includes(k) || (i.placeholder || '').toLowerCase().includes(k)
1085
+ )
1086
+ );
1087
+ const hasPasswordField = inputs.some((i: any) => i.type === 'password');
1088
+ return hasEmailOrUser && hasPasswordField;
1089
+ }).catch(() => false);
1090
+ const sessionFailed = redirectedToLogin || redirectedToError || hasLoginForm;
1091
+ if (sessionFailed) {
1092
+ logger.warn({ url: postRestoreUrl, loginForm: hasLoginForm, errorUrl: redirectedToError }, 'StorageState restore failed — session may be expired');
1093
+ // Clear the expired session from DB so user is forced to re-capture
1094
+ await AppBrain.saveCrawlConfig(opts.tenantId, opts.projectId, { storageState: null }).catch(() => { });
1095
+ // Navigate to the login page. First try appUrl — if it still redirects to an error/login
1096
+ // path, probe common login paths at the app origin so Stagehand finds a real login form.
1097
+ await page.goto(opts.appUrl, { waitUntil: 'domcontentloaded' }).catch(() => { });
1098
+ await waitForStableDOM(page);
1099
+ const urlAfterNav = page.url();
1100
+ const stillAtError = ERROR_PATH.test(new URL(urlAfterNav).pathname) || LOGIN_PATH.test(new URL(urlAfterNav).pathname);
1101
+ if (stillAtError) {
1102
+ const appOrigin = new URL(opts.appUrl).origin;
1103
+ for (const loginPath of ['/login', '/signin', '/sign-in', '/auth/login', '/auth']) {
1104
+ try {
1105
+ await page.goto(`${appOrigin}${loginPath}`, { waitUntil: 'domcontentloaded' });
1106
+ const hasForm = await page.evaluate(() => {
1107
+ const inputs = Array.from(document.querySelectorAll('input'));
1108
+ return inputs.some((i: any) => i.type === 'password');
1109
+ }).catch(() => false);
1110
+ if (hasForm) { logger.info({ loginPath }, 'Found login form at path'); break; }
1111
+ } catch { /* continue to next path */ }
1112
+ }
1113
+ await waitForStableDOM(page);
1114
+ }
1115
+ if (opts.credentials) {
1116
+ logger.info('Falling back to AI login with credentials');
1117
+ await onProgress?.(15, 'Session expired — logging in with credentials…');
1118
+ const loggedIn = await loginWithStagehand(stagehand, opts.credentials, opts.appUrl, { apiKey: opts.captchaSolverApiKey, provider: opts.captchaSolverProvider }, { apiKey: opts.mailslurpApiKey, inboxId: opts.mailslurpInboxId }, opts.loginInstructions, { tenantId: opts.tenantId, projectId: opts.projectId, jobId: opts.jobId });
1119
+ if (!loggedIn) throw new Error('[SESSION_EXPIRED] Stored session expired and credential login also failed. Check your username / password in Project Settings → Credentials.');
1120
+ didLogin = true;
1121
+ } else {
1122
+ throw new Error('[SESSION_EXPIRED] Stored session has expired. Please re-capture your session in Project Settings → Advanced: Session Capture, or add username / password as a login fallback.');
1123
+ }
1124
+ } else {
1125
+ await onProgress?.(18, 'Session restored');
1126
+ logger.info({ url: postRestoreUrl }, 'Session restored');
1127
+ }
1128
+ } catch (err) {
1129
+ const msg = String((err as Error).message ?? err);
1130
+ // Re-throw errors we threw intentionally above
1131
+ if (msg.includes('[SESSION_EXPIRED]') || msg.includes('expired')) throw err;
1132
+ logger.warn({ err }, 'StorageState restore failed, falling back to AI login');
1133
+ if (opts.credentials) {
1134
+ await onProgress?.(15, 'Session restore failed — logging in with credentials…');
1135
+ const loggedIn = await loginWithStagehand(stagehand, opts.credentials, opts.appUrl, { apiKey: opts.captchaSolverApiKey, provider: opts.captchaSolverProvider }, { apiKey: opts.mailslurpApiKey, inboxId: opts.mailslurpInboxId }, opts.loginInstructions, { tenantId: opts.tenantId, projectId: opts.projectId, jobId: opts.jobId });
1136
+ if (!loggedIn) throw new Error('[SESSION_EXPIRED] Session restore failed and credential login also failed. Check your username / password in Project Settings → Credentials.');
1137
+ didLogin = true;
1138
+ } else {
1139
+ throw new Error('[SESSION_EXPIRED] Session restore failed and no credentials were provided. Please re-capture your session in Project Settings → Advanced: Session Capture.');
1140
+ }
1141
+ }
1142
+ } else if (opts.ssoProvider && opts.ssoDomain && opts.ssoClientId && opts.credentials) {
1143
+ // ── GAP 18: SSO ROPC flow — bypass browser login entirely ────────────────
1144
+ // Get a token directly from the IdP and inject it as a Bearer header.
1145
+ await onProgress?.(12, 'SSO: fetching token from identity provider');
1146
+ try {
1147
+ const ssoToken = await getSsoToken(
1148
+ {
1149
+ provider: opts.ssoProvider as any,
1150
+ domain: opts.ssoDomain,
1151
+ clientId: opts.ssoClientId,
1152
+ clientSecret: opts.ssoClientSecret,
1153
+ scope: opts.ssoScope,
1154
+ },
1155
+ opts.credentials.username,
1156
+ opts.credentials.password
1157
+ );
1158
+ await page.setExtraHTTPHeaders({ 'Authorization': `Bearer ${ssoToken}` });
1159
+ await page.addInitScript((t: string) => {
1160
+ localStorage.setItem('access_token', t);
1161
+ localStorage.setItem('auth_token', t);
1162
+ localStorage.setItem('token', t);
1163
+ }, ssoToken);
1164
+ await page.reload({ waitUntil: 'domcontentloaded' });
1165
+ didLogin = true;
1166
+ await onProgress?.(18, 'SSO token injected');
1167
+ logger.info({ provider: opts.ssoProvider }, '[crawler] SSO token obtained and injected');
1168
+ } catch (ssoErr) {
1169
+ logger.warn({ err: ssoErr }, '[crawler] SSO token retrieval failed — falling back to browser login');
1170
+ // Fall through to browser-based login
1171
+ await onProgress?.(12, 'Logging in');
1172
+ didLogin = true;
1173
+ const loggedIn = await loginWithStagehand(stagehand, opts.credentials, opts.appUrl, { apiKey: opts.captchaSolverApiKey, provider: opts.captchaSolverProvider }, { apiKey: opts.mailslurpApiKey, inboxId: opts.mailslurpInboxId }, opts.loginInstructions, { tenantId: opts.tenantId, projectId: opts.projectId, jobId: opts.jobId });
1174
+ if (!loggedIn) {
1175
+ logger.warn('Stagehand login failed — trying Skyvern vision fallback');
1176
+ await onProgress?.(14, 'Trying vision fallback');
1177
+ const skyvernResult = await skyvernFallback({
1178
+ taskDescription: `Log in to ${opts.appUrl} using username "${opts.credentials.username}" and navigate to the main dashboard.`,
1179
+ url: opts.appUrl,
1180
+ credentials: opts.credentials,
1181
+ }, opts.skyvernConfig);
1182
+ if (skyvernResult === 'failed') {
1183
+ throw new Error(
1184
+ 'Login failed on both Stagehand (DOM) and Skyvern (vision). ' +
1185
+ 'Check credentials. For CAPTCHA/SSO sites, set SKYVERN_API_URL + SKYVERN_API_KEY.'
1186
+ );
1187
+ }
1188
+ }
1189
+ await onProgress?.(18, 'Logged in');
1190
+ }
1191
+ } else if (opts.credentials) {
1192
+ await onProgress?.(12, 'Logging in');
1193
+ didLogin = true;
1194
+ const loggedIn = await loginWithStagehand(stagehand, opts.credentials, opts.appUrl, { apiKey: opts.captchaSolverApiKey, provider: opts.captchaSolverProvider }, { apiKey: opts.mailslurpApiKey, inboxId: opts.mailslurpInboxId }, opts.loginInstructions, { tenantId: opts.tenantId, projectId: opts.projectId, jobId: opts.jobId });
1195
+ if (!loggedIn) {
1196
+ logger.warn('Stagehand login failed — trying Skyvern vision fallback');
1197
+ await onProgress?.(14, 'Trying vision fallback');
1198
+ const skyvernResult = await skyvernFallback({
1199
+ taskDescription: `Log in to ${opts.appUrl} using username "${opts.credentials.username}" and navigate to the main dashboard.`,
1200
+ url: opts.appUrl,
1201
+ credentials: opts.credentials,
1202
+ }, opts.skyvernConfig);
1203
+ if (skyvernResult === 'failed') {
1204
+ throw new Error(
1205
+ 'Login failed on both Stagehand (DOM) and Skyvern (vision). ' +
1206
+ 'Check credentials. For CAPTCHA/SSO sites, set SKYVERN_API_URL + SKYVERN_API_KEY.'
1207
+ );
1208
+ }
1209
+ }
1210
+ await onProgress?.(18, 'Logged in');
1211
+ } else {
1212
+ await onProgress?.(18, 'No auth needed');
1213
+ }
1214
+
1215
+ // Step 2: Crawl
1216
+ // If we just logged in (via credentials or session-fallback), capture and persist the new
1217
+ // session state so the next crawl reuses it without logging in again.
1218
+ if (didLogin) {
1219
+ try {
1220
+ const freshState = await playwrightBrowser!.contexts()[0]?.storageState();
1221
+ if (freshState) {
1222
+ await AppBrain.saveCrawlConfig(opts.tenantId, opts.projectId, { storageState: JSON.stringify(freshState) });
1223
+ logger.info('Fresh session state captured and saved after credential login');
1224
+ await onProgress?.(19, 'Session saved — will reuse on next crawl');
1225
+ }
1226
+ } catch (e) {
1227
+ logger.warn({ err: e }, 'Could not save refreshed session state — crawl will continue');
1228
+ }
1229
+ // Wait for SPA router to complete the post-login redirect before seeding the queue.
1230
+ // Without this, Next.js/React apps queue the login URL instead of the dashboard URL.
1231
+ await waitForURLChange(page, opts.appUrl);
1232
+ }
1233
+ const origin = new URL(page.url()).origin;
1234
+ const maxScreens = opts.maxScreens ?? Infinity;
1235
+ const segmentSize = opts.segmentSize ?? 9_999_999; // effectively unlimited — segments only exist for checkpointing reliability
1236
+ // selective = exact-URL-only mode (recrawl selected screens, no link discovery)
1237
+ const selective = !!(opts.selectiveUrls && opts.selectiveUrls.length > 0);
1238
+
1239
+ // Mark all screens for this project as active — post-crawl we'll diff which ones weren't re-discovered.
1240
+ if (!selective) {
1241
+ await AppBrain.prepareForRecrawl(opts.tenantId, opts.projectId, opts.jobId);
1242
+ await AppBrain.clearFlows(opts.tenantId, opts.projectId);
1243
+ }
1244
+ // Canonicalize URL for dedup: strip tracking params + normalize dynamic ID segments.
1245
+ // Prevents /projects/cuid1 and /projects/cuid2 from being queued as separate screens.
1246
+ const canonicalize = (raw: string): string => {
1247
+ try {
1248
+ const u = new URL(raw);
1249
+ // Treat www and non-www as the same host — most sites redirect one to the other
1250
+ // but internal links may be inconsistent, causing duplicate crawl entries otherwise.
1251
+ u.hostname = u.hostname.replace(/^www\./, '');
1252
+ // Strip params that don't change page content
1253
+ [
1254
+ 'utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'ref', 'source', '_gl', 'fbclid', 'gclid',
1255
+ 'ts', 'timestamp', 'cache', 'cacheBust', 'cache_bust', '_', 'nonce', 'session', 'sid',
1256
+ // WooCommerce / e-commerce action params
1257
+ 'add-to-cart', 'add_to_wishlist', 'quantity', 'wc-ajax', 'orderby',
1258
+ 'min_price', 'max_price', 'removed_item', 'restore', 'remove_item',
1259
+ 'undo_item', 'coupon_code', 'apply_coupon', 'added-to-cart',
1260
+ 'wc_error', 'wc_notice', 'wc_notice_type', 'update_cart',
1261
+ 'proceed', '_wpnonce', 'post_type', 'product_cat',
1262
+ // WooCommerce facet/layered-nav filter params — each combination generates a unique URL
1263
+ // but all render the same template; not stripping these causes infinite URL explosion
1264
+ // (N filter options × M values = exponential queue growth)
1265
+ 'filter_color', 'filter_colour', 'filter_size', 'filter_brand', 'filter_category',
1266
+ 'filter_rating', 'filter_stock_status', 'filter_pa_color', 'filter_pa_size',
1267
+ 'filter_pa_brand', 'filter_pa_material', 'filter_pa_weight', 'filter_pa_style',
1268
+ 'filter_pa_gender', 'filter_pa_age', 'filter_pa_type', 'filter_pa_pattern',
1269
+ 'query_type_color', 'query_type_size', 'query_type_brand', 'query_type_pa_color',
1270
+ 'query_type_pa_size', 'query_type_pa_brand', 'in_stock', 'on_sale',
1271
+ // Shopify facet filters
1272
+ 'sort_by', 'constraint', 'filter.p.tag', 'filter.v.price.gte', 'filter.v.price.lte',
1273
+ 'filter.p.m.global.color', 'filter.p.m.global.size',
1274
+ ].forEach(p => u.searchParams.delete(p));
1275
+ // Strip any remaining filter_* / query_type_* / filter.* params (WooCommerce/Shopify facets)
1276
+ for (const key of Array.from(u.searchParams.keys())) {
1277
+ if (/^filter[._]|^query_type_/i.test(key)) u.searchParams.delete(key);
1278
+ }
1279
+ u.searchParams.sort();
1280
+ // Normalize dynamic segments globally: UUIDs, CUIDs, long numeric IDs,
1281
+ // and hyphenated numeric record IDs under any parent route.
1282
+ u.pathname = u.pathname
1283
+ .replace(/\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}(?=\/|$)/gi, '/{id}')
1284
+ .replace(/\/c[a-z0-9]{20,}(?=\/|$)/g, '/{id}')
1285
+ .replace(/\/\d{4,}(?=\/|$)/g, '/{id}')
1286
+ // Hyphenated numeric IDs: digits-digits (e.g. 0-9638, 12-4567). Not UUIDs (hex only).
1287
+ .replace(/\/\d+-\d+(?=\/|$)/g, '/{id}')
1288
+ .replace(/\/+$/, '') || '/';
1289
+ // Hash-routed SPAs put the actual route in the fragment ("#/dashboard",
1290
+ // "#!/settings") — keep it so distinct routes dedup separately instead
1291
+ // of every hash-route collapsing onto one canonical (hashless) URL.
1292
+ if (!/^#!?\//.test(u.hash)) u.hash = '';
1293
+ return u.toString();
1294
+ } catch { return raw; }
1295
+ };
1296
+
1297
+ const gitInfo = getGitInfo();
1298
+
1299
+ const visited = new Map<string, string>(); // key = canonical URL
1300
+ // Pre-populate from a previous failed job so already-captured URLs are skipped
1301
+ if (opts.skipUrls?.length) {
1302
+ for (const u of opts.skipUrls) visited.set(canonicalize(u), 'skipped');
1303
+ }
1304
+ let checkpointSuccessCount = 0;
1305
+ let maxSeenTotal = 0; // track high-water mark so progress % never goes backwards
1306
+ const startUrl = page.url();
1307
+ // Resume from previous segment's pending queue if provided
1308
+ const resumeUrls = opts.resumeUrls ?? [];
1309
+ // Sitemap-discovered URLs are additional seeds (not a replacement for
1310
+ // link-following) — merged in below unless this is a selective
1311
+ // ("crawl only these URLs") run or a resumed segment, matching how
1312
+ // GraphQL auto-introspection — fire-and-forget, stored in crawl metadata
1313
+ const gqlIntrospectionPromise = (async () => {
1314
+ const gqlEndpoints = guessGraphQLEndpoints(opts.appUrl);
1315
+ for (const ep of gqlEndpoints) {
1316
+ const result = await introspectGraphQL(ep, { timeoutMs: 5_000 }).catch(() => null);
1317
+ if (result?.schema) {
1318
+ logger.info({ endpoint: ep, typeCount: result.schema.types.length }, '[crawler] GraphQL schema discovered');
1319
+ return result;
1320
+ }
1321
+ }
1322
+ return null;
1323
+ })();
1324
+
1325
+ // seedUrls itself works. Best-effort: discoverSitemapUrls() never throws.
1326
+ const sitemapUrls = (opts.useSitemap && !selective && resumeUrls.length === 0)
1327
+ ? await discoverSitemapUrls(opts.appUrl, opts.sitemapUrl)
1328
+ : [];
1329
+ // Filter bad URLs out of resumeUrls (generated dynamic routes from prior segments)
1330
+ const filteredResumeUrls = resumeUrls.filter(u => {
1331
+ try { return !isGeneratedDynamicPath(new URL(u).pathname); } catch { return true; }
1332
+ });
1333
+ const seedUrls = selective
1334
+ ? opts.selectiveUrls!
1335
+ : filteredResumeUrls.length > 0
1336
+ ? filteredResumeUrls
1337
+ : [
1338
+ startUrl,
1339
+ // User-configured seed URLs added to queue; link discovery still runs from each.
1340
+ ...(opts.startUrls ?? []).filter((u) => canonicalize(u) !== canonicalize(startUrl)),
1341
+ ...sitemapUrls.filter((u) => canonicalize(u) !== canonicalize(startUrl)),
1342
+ ];
1343
+ const queued = new Set<string>(seedUrls.map(canonicalize));
1344
+ const queue = new CrawlFrontier(opts.traversalStrategy ?? 'BFS', seedUrls, opts.bestFirstKeywords, opts.urlFilter);
1345
+ const deduplicator = new ContentDeduplicator(0.88);
1346
+ const deadLinkTracker = new DeadLinkTracker();
1347
+ const redirectRegistry = new RedirectRegistry();
1348
+ // Re-seed from previously-discovered screens so deep routes found by AI link
1349
+ // discovery in prior crawls aren't lost when a recrawl starts from scratch.
1350
+ // Doc 41 Option D: orphan-classified screens (no inbound nav edges) get a BEST_FIRST
1351
+ // score boost via parentElements:0 so they're visited early in the next crawl.
1352
+ // Only applies to full recrawls (not selective runs or segment resumptions).
1353
+ if (!selective && resumeUrls.length === 0) {
1354
+ try {
1355
+ const priorMap = projectForProfile ?? await AppBrain.getProjectMap(opts.tenantId, opts.projectId);
1356
+ const orphanReport = await AppBrain.getOrphanScreens(opts.tenantId, opts.projectId).catch(() => null);
1357
+ const orphanIdSet = new Set<string>(
1358
+ (orphanReport?.screens ?? [])
1359
+ .filter((s: any) => s.classification !== 'REACHABLE')
1360
+ .map((s: any) => s.id as string),
1361
+ );
1362
+ const priorScreens = (priorMap?.screens ?? [])
1363
+ .filter((s: any) => s.status !== 'PENDING_REMOVAL')
1364
+ .filter((s: any) => {
1365
+ const u = s.url as string | null;
1366
+ if (!u) return false;
1367
+ try {
1368
+ const { origin, pathname } = new URL(u);
1369
+ if (origin !== new URL(startUrl).origin) return false;
1370
+ // Don't re-seed generated dynamic routes — they produce duplicate templates.
1371
+ if (isGeneratedDynamicPath(pathname)) return false;
1372
+ return true;
1373
+ } catch { return false; }
1374
+ })
1375
+ .filter((s: any) => !queued.has(canonicalize(s.url as string)));
1376
+ let orphanSeedCount = 0;
1377
+ for (const s of priorScreens) {
1378
+ const u = s.url as string;
1379
+ queued.add(canonicalize(u));
1380
+ const isOrphan = orphanIdSet.has(s.id as string);
1381
+ queue.push(u, isOrphan ? { parentElements: 0 } : undefined);
1382
+ if (isOrphan) orphanSeedCount++;
1383
+ }
1384
+ if (priorScreens.length > 0) {
1385
+ logger.info(
1386
+ { count: priorScreens.length, orphanBoosted: orphanSeedCount },
1387
+ '[crawler] re-seeded prior screen URLs from DB',
1388
+ );
1389
+ }
1390
+ } catch (err) {
1391
+ logger.warn({ err: String(err) }, '[crawler] non-fatal: could not load prior screen URLs for re-seeding');
1392
+ }
1393
+ }
1394
+ // G11: Pre-auth sitemap discovery — capture public pages before session injection.
1395
+ // Many apps have marketing/pricing/docs pages only reachable without auth.
1396
+ // We fetch the sitemap, visit each URL, and screenshot pages that DON'T redirect to login.
1397
+ if (opts.useSitemap !== false) {
1398
+ const g11SitemapUrls: string[] = [];
1399
+ const sitemapCandidates = [
1400
+ `${opts.appUrl.replace(/\/$/, '')}/sitemap.xml`,
1401
+ `${opts.appUrl.replace(/\/$/, '')}/sitemap_index.xml`,
1402
+ `${opts.appUrl.replace(/\/$/, '')}/robots.txt`,
1403
+ ];
1404
+ const LOGIN_REDIRECT_PATH = /\/(login|signin|sign-in|auth|log-in)(\/|$|\?)/i;
1405
+
1406
+ try {
1407
+ for (const candidate of sitemapCandidates) {
1408
+ try {
1409
+ const resp = await fetch(candidate, { signal: AbortSignal.timeout(8_000) }).catch(() => null);
1410
+ if (!resp?.ok) continue;
1411
+ const text = await resp.text();
1412
+ if (candidate.endsWith('robots.txt')) {
1413
+ const sitemapLines = text.split('\n').filter((l: string) => l.toLowerCase().startsWith('sitemap:'));
1414
+ for (const line of sitemapLines) {
1415
+ const u = line.split(':').slice(1).join(':').trim();
1416
+ if (u) sitemapCandidates.push(u);
1417
+ }
1418
+ continue;
1419
+ }
1420
+ const locs = text.match(/<loc>([^<]+)<\/loc>/gi) ?? [];
1421
+ for (const loc of locs) {
1422
+ const u = loc.replace(/<\/?loc>/gi, '').trim();
1423
+ if (u.startsWith('http')) g11SitemapUrls.push(u);
1424
+ }
1425
+ if (g11SitemapUrls.length > 0) break;
1426
+ } catch { /* try next candidate */ }
1427
+ }
1428
+
1429
+ if (g11SitemapUrls.length > 0) {
1430
+ logger.info({ count: g11SitemapUrls.length }, '[crawler] G11: sitemap discovered public URL candidates');
1431
+ const sitemapOrigin = new URL(opts.appUrl).origin;
1432
+ let sitemapCaptured = 0;
1433
+ for (const sUrl of g11SitemapUrls.slice(0, 30)) {
1434
+ try {
1435
+ const u = new URL(sUrl);
1436
+ if (u.origin !== sitemapOrigin) continue;
1437
+ await page.goto(sUrl, { waitUntil: 'domcontentloaded', timeout: 12_000 });
1438
+ await page.waitForLoadState('networkidle', { timeout: 5_000 }).catch(() => { });
1439
+ await waitForStableDOM(page);
1440
+ const landedUrl = page.url();
1441
+ if (LOGIN_REDIRECT_PATH.test(new URL(landedUrl).pathname)) {
1442
+ const canon = canonicalize(sUrl);
1443
+ if (!queued.has(canon)) { queued.add(canon); queue.push(sUrl); }
1444
+ logger.info({ url: sUrl }, '[crawler] G11: sitemap URL requires auth — added to BFS queue');
1445
+ continue;
1446
+ }
1447
+ const pagePath = u.pathname.replace(/^\//, '') || 'home';
1448
+ const pageName = pagePath.split('/').map((s: string) => s.charAt(0).toUpperCase() + s.slice(1)).join(' ') || 'Home';
1449
+ const shotPath = path.join(opts.screenshotDir ?? '/tmp', `sitemap-preauth-${sitemapCaptured}.png`);
1450
+ await waitForScreenshotReady(page);
1451
+ await page.screenshot({ path: shotPath, fullPage: true }).catch(() => { });
1452
+ const pubScreen = await AppBrain.saveScreen(opts.tenantId, opts.projectId, pageName, sUrl, opts.jobId, 'SITEMAP').catch(() => null);
1453
+ if (pubScreen) {
1454
+ await AppBrain.saveScreenshot(opts.tenantId, opts.projectId, pubScreen.id, shotPath, pubScreen.version, 'DESKTOP').catch(() => { });
1455
+ logger.info({ url: sUrl, name: pageName }, '[crawler] G11: captured public sitemap page');
1456
+ }
1457
+ sitemapCaptured++;
1458
+ } catch (sitemapPageErr) {
1459
+ logger.warn({ url: sUrl, err: String(sitemapPageErr) }, '[crawler] G11: sitemap page failed — skipping');
1460
+ }
1461
+ }
1462
+ logger.info({ captured: sitemapCaptured }, '[crawler] G11: pre-auth sitemap capture complete');
1463
+ await page.goto(opts.appUrl, { waitUntil: 'domcontentloaded', timeout: 15_000 }).catch(() => { });
1464
+ await waitForStableDOM(page);
1465
+ }
1466
+ } catch (sitemapErr) {
1467
+ logger.warn({ err: String(sitemapErr) }, '[crawler] G11: pre-auth sitemap discovery failed — continuing');
1468
+ }
1469
+ }
1470
+
1471
+ // Discovery-method tracking for orphan-page detection (see AppBrain.getOrphanScreens):
1472
+ // the entry point is never an "orphan" candidate; anything else in the initial seed
1473
+ // batch is a configured seed URL (sitemap-discovered URLs included), not something
1474
+ // reached by following a link.
1475
+ const canonicalEntryUrl = canonicalize(startUrl);
1476
+ const canonicalSitemapUrls = new Set(sitemapUrls.map(canonicalize));
1477
+ const canonicalSeedUrls = new Set(seedUrls.map(canonicalize));
1478
+ const discoveryMethodFor = (url: string): 'ENTRY_POINT' | 'SEED_URL' | 'SITEMAP' | 'LINK_FOLLOW' => {
1479
+ const c = canonicalize(url);
1480
+ if (c === canonicalEntryUrl) return 'ENTRY_POINT';
1481
+ if (canonicalSitemapUrls.has(c)) return 'SITEMAP';
1482
+ if (canonicalSeedUrls.has(c)) return 'SEED_URL';
1483
+ return 'LINK_FOLLOW';
1484
+ };
1485
+ const edges: Array<{ fromUrl: string; toUrl: string }> = [];
1486
+ const pendingTransitions: Array<{ fromScreenId: string; elementMeaning: string; targetUrl: string }> = [];
1487
+ const discovered: Array<{
1488
+ url: string; screenId?: string; elements?: number;
1489
+ elementFacts?: Array<{ meaning: string; role: string; expectedData?: string }>;
1490
+ screenshotPath?: string; version?: number; error?: string;
1491
+ }> = [];
1492
+ const performanceMetrics: PerformanceMetric[] = [];
1493
+ const seenDomHashes = new Set<string>(opts.knownDomHashes ?? []);
1494
+
1495
+ // Content/legal pages — still discovered as links but skipped from crawl queue
1496
+ // (no test case value; pollute element counts with nav noise)
1497
+ const SKIP_PATH = /\/(terms|privacy|dpa|legal|cookie|changelog|blog|help|docs|about|contact|careers|press|imprint|gdpr|compliance|acceptable-use|security-policy|accessibility)([-/]|$)/i;
1498
+ const segIdx = opts.segmentIndex ?? 1;
1499
+
1500
+ const robotsCache = opts.respectRobotsTxt ? new RobotsCache() : null;
1501
+ // AutoThrottle state — exponential moving average of navigation latency
1502
+ let avgNavMs = 0;
1503
+ let navCount = 0;
1504
+ // Rate-limit circuit breaker: after 3 consecutive AI errors (429), skip AI for 10 screens
1505
+ let aiErrorStreak = 0;
1506
+ let aiSkipCount = 0;
1507
+ let aiRateLimitWarned = false;
1508
+ // Hard guard: total URLs attempted (including skips) capped at maxScreens * 4
1509
+ // to prevent infinite-URL-explosion from WooCommerce/Shopify facet filter combos
1510
+ // that slip through canonicalize() if any new filter param type is encountered.
1511
+ let totalAttemptsThisSegment = 0;
1512
+ const maxAttempts = Math.max(segmentSize * 4, 200);
1513
+
1514
+ while (queue.length > 0 && visited.size < maxScreens && visited.size < segmentSize) {
1515
+ if (++totalAttemptsThisSegment > maxAttempts) {
1516
+ logger.warn({ maxAttempts, visited: visited.size, queued: queue.length }, '[crawler] attempt cap reached — stopping to prevent URL explosion');
1517
+ break;
1518
+ }
1519
+ if (opts.abortSignal?.aborted) {
1520
+ logger.info('Abort signal received — stopping crawl');
1521
+ throw new Error('CRAWL_ABORTED');
1522
+ }
1523
+ // AutoThrottle: delay next request proportional to avg navigation latency
1524
+ if (opts.autoThrottle && navCount > 0) {
1525
+ const delayMs = Math.min(5_000, Math.max(0, avgNavMs * 0.5));
1526
+ if (delayMs > 100) await page.waitForTimeout(delayMs).catch(() => { });
1527
+ }
1528
+
1529
+ const url = queue.shift()!;
1530
+ const canonUrl = canonicalize(url);
1531
+ logger.info({ url, visited: visited.size, queued: queue.length, segment: `${visited.size + 1}/${segmentSize}` }, '[crawler] visiting URL');
1532
+ const apiObservationStartIndex = capturedApiEndpoints.length;
1533
+
1534
+ // Skip if already visited (canonical match — catches ID-parameterized duplicates)
1535
+ if (visited.has(canonUrl)) continue;
1536
+
1537
+ // robots.txt compliance — skip disallowed URLs
1538
+ if (robotsCache && !await robotsCache.isAllowed(url)) {
1539
+ logger.info({ url }, '[crawler] robots.txt: skipping disallowed URL');
1540
+ continue;
1541
+ }
1542
+
1543
+ // Skip content/legal pages — they have no interactive form elements worth testing
1544
+ try {
1545
+ const { pathname } = new URL(url);
1546
+ if (SKIP_PATH.test(pathname)) {
1547
+ console.log(`[crawler] skip content page: ${url}`);
1548
+ continue;
1549
+ }
1550
+ // Generated ID-like path segments are usually fallback IDs or entity
1551
+ // records. They flood the catalog with duplicate page templates, so
1552
+ // skip them before screenshots or screen creation.
1553
+ if (isGeneratedDynamicPath(pathname)) {
1554
+ console.log(`[crawler] skip generated dynamic route: ${url}`);
1555
+ continue;
1556
+ }
1557
+ } catch { /* invalid url, let it fall through */ }
1558
+
1559
+ try {
1560
+ // Security headers — capture from first document response via CDP.
1561
+ // Stagehand V3's page proxy only supports the 'console' event (page.on throws for others).
1562
+ // We hook directly into the underlying CDP session's Network.responseReceived event instead.
1563
+ let securityHeaders: Record<string, string | null> | null = null;
1564
+ let httpStatus: number | null = null;
1565
+ const urlBase = url.replace(/[?#].*/, '');
1566
+ const cdpSession: any = (page as any).mainSession ?? (page as any)._session ?? null;
1567
+ const secHeadersCdp = (evt: any) => {
1568
+ if (securityHeaders) return;
1569
+ try {
1570
+ const responseUrl: string = evt?.response?.url ?? '';
1571
+ if (!responseUrl.startsWith(urlBase)) return;
1572
+ httpStatus = evt?.response?.status ?? null;
1573
+ const headers: Record<string, string> = {};
1574
+ // CDP headers come as array of {name, value} objects
1575
+ for (const h of (evt?.response?.headers ?? [])) headers[h.name.toLowerCase()] = h.value;
1576
+ securityHeaders = {
1577
+ csp: headers['content-security-policy'] ?? headers['content-security-policy-report-only'] ?? null,
1578
+ hsts: headers['strict-transport-security'] ?? null,
1579
+ xContentType: headers['x-content-type-options'] ?? null,
1580
+ xFrame: headers['x-frame-options'] ?? null,
1581
+ referrer: headers['referrer-policy'] ?? null,
1582
+ };
1583
+ } catch { /* non-fatal */ }
1584
+ };
1585
+ if (cdpSession?.on) cdpSession.on('Network.responseReceived', secHeadersCdp);
1586
+
1587
+ const wsDetector = attachWebSocketDetector(cdpSession);
1588
+
1589
+ if (url !== page.url()) {
1590
+ // LCP/CLS observers are registered via the addInitScript above, which
1591
+ // re-runs automatically on this navigation — nothing to do here.
1592
+ const navStart = Date.now();
1593
+ // Track redirect hops during this navigation
1594
+ const redirectHops: Array<{ from: string; to: string; statusCode: number }> = [];
1595
+ const onRedirectResponse = (res: any) => {
1596
+ const st = res.status();
1597
+ if (st >= 300 && st < 400) {
1598
+ redirectHops.push({ from: res.url(), to: res.headers()['location'] ?? '', statusCode: st });
1599
+ } else if (st >= 400) {
1600
+ deadLinkTracker.record(res.url(), st, { sourceUrl: url });
1601
+ }
1602
+ };
1603
+ rawPage.on('response', onRedirectResponse);
1604
+ await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30_000 }).catch((navErr: any) => {
1605
+ logger.warn({ url, err: navErr?.message ?? String(navErr) }, '[crawler] navigation failed — skipping');
1606
+ });
1607
+ rawPage.off('response', onRedirectResponse);
1608
+ if (redirectHops.length > 0) {
1609
+ redirectRegistry.register({
1610
+ originalUrl: url, finalUrl: page.url(),
1611
+ hops: redirectHops.map((h, i) => ({ ...h, durationMs: i === 0 ? Date.now() - navStart : 0 })),
1612
+ isCircular: new Set(redirectHops.map(h => h.from)).size < redirectHops.length,
1613
+ hopCount: redirectHops.length,
1614
+ totalDurationMs: Date.now() - navStart,
1615
+ });
1616
+ }
1617
+ // Update exponential moving average for AutoThrottle
1618
+ const navMs = Date.now() - navStart;
1619
+ navCount++;
1620
+ avgNavMs = avgNavMs + (navMs - avgNavMs) / navCount;
1621
+ }
1622
+ // chrome-error:// = TCP/HTTP connection failed (IP block, DNS failure, etc.)
1623
+ // Skip immediately — don't screenshot, don't trigger Skyvern, just move on.
1624
+ if (page.url().startsWith('chrome-error://')) {
1625
+ console.warn(`[crawler] connection failed for ${url} — skipping (chrome-error page)`);
1626
+ continue;
1627
+ }
1628
+ // Full page-ready sequence:
1629
+ // 1. networkidle — waits for XHR/fetch after initial load (data grids, dashboards)
1630
+ // 2. DOM stability — waits for React/Vue hydration to finish mutating the DOM
1631
+ // 3. Modal capture — screenshot app dialogs BEFORE dismissal (they are testable UI states)
1632
+ // 4. Overlay dismissal — removes cookie banners/modals before element extraction
1633
+ // 5. Lazy-load scroll — triggers IntersectionObserver for off-screen images/components
1634
+ await page.waitForLoadState('networkidle', { timeout: 8_000 }).catch(() => { });
1635
+ await waitForStableDOM(page);
1636
+
1637
+ if (!opts.discoveryMode) {
1638
+ // Capture app-generated modals BEFORE dismissing — cookie/GDPR banners excluded.
1639
+ try {
1640
+ const appModalVisible = await page.evaluate(() => {
1641
+ const dialogs = Array.from(document.querySelectorAll('[role="dialog"], [role="alertdialog"], [aria-modal="true"]'));
1642
+ return dialogs.some((el: any) => {
1643
+ const text = (el.textContent ?? '').toLowerCase();
1644
+ const isCookieBanner = /(cookie|consent|gdpr|privacy policy|accept all|we use cookies)/i.test(text);
1645
+ const isVisible = el.offsetParent !== null || el.style.display !== 'none';
1646
+ return isVisible && !isCookieBanner;
1647
+ });
1648
+ }).catch(() => false);
1649
+ if (appModalVisible) {
1650
+ const modalHash = url.replace(/[^a-z0-9]/gi, '').slice(-12);
1651
+ const modalShotPath = path.join(opts.screenshotDir ?? '/tmp', `modal-${modalHash}.png`);
1652
+ await page.screenshot({ path: modalShotPath, fullPage: false }).catch(() => { });
1653
+ const modalName = await page.evaluate(() => {
1654
+ const h = document.querySelector('[role="dialog"] h1,[role="dialog"] h2,[role="dialog"] [role="heading"]');
1655
+ return h?.textContent?.trim() ?? 'Modal';
1656
+ }).catch(() => 'Modal');
1657
+ const modalScreen = await AppBrain.saveScreen(opts.tenantId, opts.projectId, modalName, url, opts.jobId, discoveryMethodFor(url)).catch(() => null);
1658
+ if (modalScreen) {
1659
+ await AppBrain.saveScreenshot(opts.tenantId, opts.projectId, modalScreen.id, modalShotPath, modalScreen.version, 'DESKTOP').catch(() => { });
1660
+ logger.info({ url, name: modalName }, '[crawler] captured app modal before dismiss');
1661
+ }
1662
+ }
1663
+ } catch { /* non-fatal */ }
1664
+ await dismissOverlays(page);
1665
+ // Enhanced infinite scroll — handles lazy-load, infinite lists, and virtual scrollers
1666
+ await infiniteScrollToBottom(page, { maxScrolls: 30, scrollDelay: 350, stabilizeMs: 500 }).catch(() => { });
1667
+ }
1668
+ if (cdpSession?.off) cdpSession.off('Network.responseReceived', secHeadersCdp);
1669
+ wsDetector.detach();
1670
+
1671
+ // 429 Rate-limit — exponential backoff with jitter before continuing
1672
+ if (httpStatus === 429) {
1673
+ const backoffMs = Math.min(60_000, 1_000 * Math.pow(2, Math.min(navCount, 6)));
1674
+ logger.warn({ url, httpStatus, backoffMs }, '[crawler] 429 rate-limited — backing off');
1675
+ await new Promise(r => setTimeout(r, backoffMs + Math.random() * 1_000));
1676
+ }
1677
+
1678
+ // Skip pages that returned an HTTP 4xx/5xx response — SPAs normally return 200 for all routes,
1679
+ // so a real HTTP error means the server knows this URL is invalid.
1680
+ if (httpStatus !== null && httpStatus >= 400) {
1681
+ logger.info({ url, httpStatus }, '[crawler] skip — server returned HTTP error');
1682
+ continue;
1683
+ }
1684
+
1685
+ // ── Mid-crawl session expiry detection ───────────────────────────────────
1686
+ // Only trigger re-auth when the app REDIRECTED us away from the intended URL.
1687
+ // If we intentionally navigate to /session-expired or /login — save it as a screen (it's
1688
+ // a real page worth testing: messaging, recovery buttons, error UX, etc.).
1689
+ const BFS_ERROR_PATH = /\/(session-expired|expired|unauthorized|forbidden|401|403|logout|logged-out)(\/|$|\?)/i;
1690
+ const BFS_LOGIN_PATH = /\/(login|signin|sign-in|auth|log-in|sso|account\/login)(\/|$|\?)/i;
1691
+ const landedUrl = page.url();
1692
+ const weWantedThisPage = BFS_ERROR_PATH.test(new URL(url).pathname) || BFS_LOGIN_PATH.test(new URL(url).pathname);
1693
+ const redirectedToError = !weWantedThisPage && (
1694
+ BFS_ERROR_PATH.test(new URL(landedUrl).pathname) || BFS_LOGIN_PATH.test(new URL(landedUrl).pathname)
1695
+ );
1696
+ if (redirectedToError) {
1697
+ // We asked for /dashboard but landed on /session-expired — session expired mid-crawl.
1698
+ logger.warn({ requested: url, landed: landedUrl }, '[crawler] Mid-crawl redirect to auth/error — attempting re-auth');
1699
+ let reauthed = false;
1700
+ if (opts.credentials) {
1701
+ reauthed = await loginWithStagehand(
1702
+ stagehand, opts.credentials, opts.appUrl,
1703
+ { apiKey: opts.captchaSolverApiKey, provider: opts.captchaSolverProvider },
1704
+ { apiKey: opts.mailslurpApiKey, inboxId: opts.mailslurpInboxId },
1705
+ undefined,
1706
+ { tenantId: opts.tenantId, projectId: opts.projectId, jobId: opts.jobId },
1707
+ ).catch(() => false);
1708
+ if (reauthed) {
1709
+ logger.info({ url }, '[crawler] Re-auth succeeded — retrying URL');
1710
+ await page.goto(url, { waitUntil: 'domcontentloaded' }).catch(() => { });
1711
+ await page.waitForLoadState('networkidle', { timeout: 8_000 }).catch(() => { });
1712
+ await waitForStableDOM(page);
1713
+ const afterReauthUrl = page.url();
1714
+ if (BFS_ERROR_PATH.test(new URL(afterReauthUrl).pathname) || BFS_LOGIN_PATH.test(new URL(afterReauthUrl).pathname)) {
1715
+ logger.warn({ url }, '[crawler] Still redirecting after re-auth — skipping this URL');
1716
+ continue;
1717
+ }
1718
+ } else {
1719
+ logger.warn({ url }, '[crawler] Re-auth failed — stopping BFS (session unrecoverable)');
1720
+ AppBrain.saveCrawlEvidence(opts.tenantId, opts.projectId, {
1721
+ jobId: opts.jobId,
1722
+ type: 'AUTH_FAILURE',
1723
+ payload: { url, landed: landedUrl, reason: 'Re-authentication failed. Session is unrecoverable. Re-capture the session in project settings and retry the crawl.' },
1724
+ }).catch(() => { });
1725
+ break;
1726
+ }
1727
+ } else {
1728
+ logger.warn('[crawler] Session expired mid-crawl, no credentials to recover — stopping BFS');
1729
+ AppBrain.saveCrawlEvidence(opts.tenantId, opts.projectId, {
1730
+ jobId: opts.jobId,
1731
+ type: 'AUTH_FAILURE',
1732
+ payload: { url, landed: landedUrl, reason: 'Session expired and no credentials are configured. Add credentials in project settings and re-capture the session.' },
1733
+ }).catch(() => { });
1734
+ break;
1735
+ }
1736
+ }
1737
+ // If weWantedThisPage (intentional /login, /session-expired, /404 navigation) —
1738
+ // fall through and save it as a real screen. Error pages are legitimate test targets.
1739
+ if (!redirectedToError && landedUrl !== url) {
1740
+ logger.info({ requested: url, landed: landedUrl }, '[crawler] navigated with redirect');
1741
+ }
1742
+
1743
+ // Discovery mode fast-path: record URL + extract DOM links only; skip screenshots/DB/AI.
1744
+ // IMPORTANT: DOM hash dedup is intentionally DISABLED in discovery mode — SPAs use identical
1745
+ // page shells for every route, so hashing would skip all pages after the first one.
1746
+ if (opts.discoveryMode) {
1747
+ visited.set(canonicalize(url), url); // key=canonical for dedup; value=original URL returned to caller
1748
+
1749
+ // ── SPA hydration wait ───────────────────────────────────────────────────
1750
+ // 'domcontentloaded' fires before JS executes, so shadow DOM doesn't exist yet.
1751
+ // Wait for network idle to ensure JS bundles are downloaded + executed,
1752
+ // then add a brief buffer for component rendering (setTimeout/rAF-deferred work).
1753
+ await (rawPage ?? page).waitForLoadState('networkidle', { timeout: 10_000 }).catch(() => { });
1754
+ await page.waitForTimeout(500).catch(() => { });
1755
+
1756
+ // ── Link extraction — three approaches, merged ───────────────────────────
1757
+ // 1. Playwright native locator (auto-pierces shadow DOM in Playwright 1.20+)
1758
+ // Works universally for React, Vue, Angular, Polymer, Lit, Stencil, FAST.
1759
+ // 2. BFS shadow DOM traversal via evaluate() — catches any gaps from approach 1.
1760
+ // 3. SPA route manifests (Next.js __NEXT_DATA__, React Router registries).
1761
+ const rawLinksSet = new Set<string>();
1762
+ const addLink = (h: string) => {
1763
+ if (!h || h === '#' || h.startsWith('javascript:') || h.startsWith('mailto:') || h.startsWith('tel:')) return;
1764
+ try {
1765
+ const href = new URL(h, origin).toString();
1766
+ if (isUsefulNavigationUrl(href, origin)) rawLinksSet.add(href);
1767
+ } catch {
1768
+ // ignore malformed links
1769
+ }
1770
+ };
1771
+
1772
+ // 1️⃣ Playwright locator — shadow-DOM-piercing (primary approach)
1773
+ const pwPage = rawPage ?? page;
1774
+ await pwPage.locator('a[href]').evaluateAll((anchors: Element[]) =>
1775
+ anchors.map((a: any) => a.href || a.getAttribute('href') || '')
1776
+ ).then((hrefs: string[]) => hrefs.forEach(addLink)).catch(() => { });
1777
+
1778
+ await pwPage.locator('[data-href],[data-url],[data-link]').evaluateAll((els: Element[]) =>
1779
+ els.map((el: any) => el.dataset?.href || el.dataset?.url || el.dataset?.link || '')
1780
+ ).then((hrefs: string[]) => hrefs.forEach(addLink)).catch(() => { });
1781
+
1782
+ await pwPage.locator('[href]:not(a):not(link):not(base):not(script):not(style)').evaluateAll((els: Element[]) =>
1783
+ els.map((el: any) => el.getAttribute('href') || '')
1784
+ ).then((hrefs: string[]) => hrefs.forEach(addLink)).catch(() => { });
1785
+
1786
+ const playwrightCount = rawLinksSet.size;
1787
+ console.log(`[discover] playwright-locator found ${playwrightCount} links for ${url}`);
1788
+
1789
+ // 2️⃣ BFS shadow DOM evaluate — fallback / complement for stubborn SPAs
1790
+ // Runs regardless so it can catch elements the locator misses.
1791
+ const evalPage = rawPage ?? page;
1792
+ await evalPage.evaluate((o: string) => {
1793
+ try {
1794
+ const hrefs: string[] = [];
1795
+ const add = (h: string) => {
1796
+ if (h && h !== '#' && !h.startsWith('javascript:') && !h.startsWith('mailto:') && !h.startsWith('tel:')) {
1797
+ if (h.startsWith(o) || h.startsWith('/')) hrefs.push(h);
1798
+ }
1799
+ };
1800
+ const queue: any[] = [document];
1801
+ const seenRoots = new WeakSet<any>();
1802
+ seenRoots.add(document);
1803
+ let shadowRootsFound = 0;
1804
+ while (queue.length > 0) {
1805
+ const root: any = queue.shift();
1806
+ if (!root) continue;
1807
+ try {
1808
+ root.querySelectorAll('a[href]').forEach((a: any) => add(a.href || a.getAttribute('href') || ''));
1809
+ root.querySelectorAll('[data-href],[data-url],[data-link]').forEach((el: any) => {
1810
+ add((el.dataset && (el.dataset.href || el.dataset.url || el.dataset.link)) || '');
1811
+ });
1812
+ root.querySelectorAll('[href]:not(a):not(link):not(base):not(script):not(style)').forEach((el: any) => {
1813
+ const h = el.getAttribute('href') || '';
1814
+ if (h && !h.startsWith('#')) add(h);
1815
+ });
1816
+ root.querySelectorAll('*').forEach((el: any) => {
1817
+ try {
1818
+ if (el.shadowRoot && !seenRoots.has(el.shadowRoot)) {
1819
+ seenRoots.add(el.shadowRoot);
1820
+ queue.push(el.shadowRoot);
1821
+ shadowRootsFound++;
1822
+ }
1823
+ } catch (_) { /* closed shadow root — skip */ }
1824
+ });
1825
+ } catch (_) { /* element threw — skip */ }
1826
+ }
1827
+ return { hrefs, shadowRootsFound };
1828
+ } catch { return { hrefs: [] as string[], shadowRootsFound: 0 }; }
1829
+ }, origin).then((result: any) => {
1830
+ (result?.hrefs ?? []).forEach(addLink);
1831
+ if (result?.shadowRootsFound > 0) {
1832
+ console.log(`[discover] bfs found ${result.shadowRootsFound} shadow roots, added ${rawLinksSet.size - playwrightCount} extra links for ${url}`);
1833
+ }
1834
+ }).catch(() => { });
1835
+
1836
+ // 3️⃣ SPA route manifests
1837
+ await evalPage.evaluate(() => {
1838
+ const routes: string[] = [];
1839
+ try {
1840
+ const nd = (window as any).__NEXT_DATA__;
1841
+ if (nd?.page) routes.push(nd.page);
1842
+ } catch (_) { /* non-fatal */ }
1843
+ try {
1844
+ const w = window as any;
1845
+ const rr = w.__reactRouterRoutes || (w.__reactRouterManifest?.routes);
1846
+ if (rr) {
1847
+ const collect = (r: any) => {
1848
+ if (r?.path && r.path !== '*') routes.push(r.path.startsWith('/') ? r.path : '/' + r.path);
1849
+ if (r?.children) r.children.forEach(collect);
1850
+ };
1851
+ (Array.isArray(rr) ? rr : Object.values(rr)).forEach(collect);
1852
+ }
1853
+ } catch (_) { /* non-fatal */ }
1854
+ return routes;
1855
+ }).then((routes: any[]) => {
1856
+ (routes ?? []).forEach(p => {
1857
+ try { addLink(new URL(p, origin).href); } catch { /* ignore */ }
1858
+ });
1859
+ }).catch(() => { });
1860
+
1861
+ const rawLinks = Array.from(rawLinksSet);
1862
+ console.log(`[discover] ${url} → rawLinks=${rawLinks.length} origin=${origin} pageUrl=${page.url()}`);
1863
+ if (rawLinks.length > 0) console.log(`[discover] sample links: ${rawLinks.slice(0, 5).join(' | ')}`);
1864
+ let newlyQueued = 0;
1865
+ for (const link of rawLinks) {
1866
+ try {
1867
+ const resolved = link.startsWith('/') ? `${origin}${link}` : link;
1868
+ const u = new URL(resolved);
1869
+ // Keep hash-router fragments (#/route, #!/route) — strip plain anchors (#section)
1870
+ if (!/^#!?\//.test(u.hash)) u.hash = '';
1871
+ const clean = u.toString();
1872
+ if (!isUsefulNavigationUrl(clean, origin)) continue;
1873
+ // Dedup: use canonical form to avoid crawling 1000 product detail pages separately
1874
+ const norm = canonicalize(clean);
1875
+ if (!visited.has(norm) && !queued.has(norm)) { queued.add(norm); queue.push(clean); newlyQueued++; }
1876
+ } catch { /* ignore malformed */ }
1877
+ }
1878
+ if (newlyQueued > 0) console.log(`[discover] queued ${newlyQueued} new URLs, queue size=${queue.length}`);
1879
+ const pct = 10 + Math.round((visited.size / Math.max(1, opts.maxScreens ?? 1000)) * 80);
1880
+ await onProgress?.(Math.min(pct, 89), `Discovered ${visited.size} URLs…`);
1881
+ // Stream partial results to caller so they can be persisted/shown incrementally
1882
+ if (opts.onUrlsDiscovered) {
1883
+ await opts.onUrlsDiscovered(Array.from(visited.values())).catch(() => { });
1884
+ }
1885
+ continue;
1886
+ }
1887
+
1888
+ // Normal crawl mode: DOM hash dedup — detect duplicate-structure pages (SPA 404s, template clones).
1889
+ const preDomHash = await computeDomHash(page);
1890
+ if (seenDomHashes.has(preDomHash)) {
1891
+ console.log(`[crawler] skip ${url} — duplicate DOM structure (same content as prior page)`);
1892
+ continue;
1893
+ }
1894
+ const pageTitle = await page.title().catch(() => '');
1895
+ if (/\b(not found|404|page not found|this page could not be found)\b/i.test(pageTitle)) {
1896
+ console.log(`[crawler] skip ${url} — error page detected (title: "${pageTitle}")`);
1897
+ seenDomHashes.add(preDomHash); // register so future 404 pages also hit hash-dedup
1898
+ continue;
1899
+ }
1900
+ // Do NOT add real pages to seenDomHashes — server-rendered sites (WooCommerce, WordPress)
1901
+ // use the same template for many legitimate pages; registering the hash would skip them all.
1902
+
1903
+ // Collect Navigation Timing + Paint Timing immediately after DOM stable.
1904
+ // LCP/CLS come from the buffered PerformanceObservers registered in the
1905
+ // init script above (the synchronous timeline never holds LCP entries).
1906
+ const rawPerf = await page.evaluate(() => {
1907
+ const nav = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming | undefined;
1908
+ const paints = performance.getEntriesByType('paint');
1909
+ // Prefer pre-registered observer value (window.__zetaLCP) over buffered entries.
1910
+ // Buffered entries are often empty if the observer wasn't registered before navigation.
1911
+ const lcpFromObserver: number | null = (window as any).__zetaLCP ?? null;
1912
+ const lcpEntries = performance.getEntriesByType('largest-contentful-paint');
1913
+ const lcpFromEntries = lcpEntries.length > 0
1914
+ ? Math.round((lcpEntries[lcpEntries.length - 1] as any).startTime)
1915
+ : undefined;
1916
+ // @ts-ignore — stashed by the CLS init script
1917
+ // @ts-ignore — stashed by the web-vitals init script
1918
+ const stashedLcp: number | null = (window as any).__zetaLCP ?? null;
1919
+ // @ts-ignore
1920
+ const stashedCls: number | undefined = (window as any).__zetaCLS;
1921
+ return {
1922
+ ttfb: nav ? Math.round(nav.responseStart - nav.fetchStart) : undefined,
1923
+ fcp: paints.find(p => p.name === 'first-contentful-paint')?.startTime
1924
+ ? Math.round(paints.find(p => p.name === 'first-contentful-paint')!.startTime)
1925
+ : undefined,
1926
+ lcp: lcpFromObserver != null ? Math.round(lcpFromObserver) : lcpFromEntries,
1927
+ cls: typeof stashedCls === 'number' ? Math.round(stashedCls * 1000) / 1000 : undefined,
1928
+ };
1929
+ }).catch(() => null);
1930
+
1931
+ // Accessibility audit via axe-core (WCAG 2.0 A/AA + best-practice)
1932
+ const a11yResult = await (async () => {
1933
+ try {
1934
+ const _require = createRequire(import.meta.url);
1935
+ const axePath = _require.resolve('axe-core');
1936
+ const axeScript = readFileSync(axePath, 'utf8');
1937
+ await page.addScriptTag({ content: axeScript });
1938
+ const result = await page.evaluate(async () => {
1939
+ return (window as any).axe.run(document, {
1940
+ runOnly: { type: 'tag', values: ['wcag2a', 'wcag2aa', 'best-practice'] },
1941
+ resultTypes: ['violations'],
1942
+ });
1943
+ });
1944
+ // Full violation objects for DB storage (Phase 4.4)
1945
+ const violations = result.violations.map((v: any) => ({
1946
+ impact: v.impact as string,
1947
+ rule: v.id as string,
1948
+ description: v.description as string,
1949
+ // First offending node's outer HTML (truncated later in saveA11yViolations)
1950
+ nodeHtml: (v.nodes?.[0]?.html as string | undefined) ?? undefined,
1951
+ wcagTags: (v.tags as string[]).filter((t: string) => t.startsWith('wcag') || t.startsWith('best-')),
1952
+ }));
1953
+ const criticalCount = violations.filter((v: any) => v.impact === 'critical').length;
1954
+ return { violations, violationCount: violations.length, criticalCount };
1955
+ } catch { return null; }
1956
+ })();
1957
+ const a11yScore = a11yResult
1958
+ ? Math.max(0, 100 - (a11yResult.criticalCount * 20) - ((a11yResult.violationCount - a11yResult.criticalCount) * 5))
1959
+ : null;
1960
+
1961
+
1962
+ // Screen name strategy (SPA-aware):
1963
+ // 1. Title with page-specific segment (e.g. "Dashboard | ZeTA")
1964
+ // 2. aria-current="page" active nav item (reliable for SPAs)
1965
+ // 3. Page-specific headings that are NOT the brand name
1966
+ // 4. URL path segments (skipping cuid/UUIDs/numerics)
1967
+ const name = await (async () => {
1968
+ let brandName = '';
1969
+
1970
+ // Compute reliable URL path-based name — skip cuid/UUID/numeric segments
1971
+ let pathName = 'Home';
1972
+ try {
1973
+ const u = new URL(url);
1974
+ const hostname = u.hostname.replace(/^www\./, '').split('.')[0]!.toLowerCase();
1975
+ const allSegs = u.pathname.split('/').filter(Boolean);
1976
+ const IS_ID = (s: string) =>
1977
+ /^\d+$/.test(s) || // numeric
1978
+ /^[0-9a-f]{8,}$/i.test(s) || // hex id
1979
+ /^[a-z]{1,2}[0-9a-z]{20,}$/i.test(s); // cuid/cuid2 (e.g. cm...)
1980
+
1981
+ const readable = allSegs
1982
+ .filter((s) => !IS_ID(s))
1983
+ .map((s) => s.charAt(0).toUpperCase() + s.slice(1).replace(/[-_]/g, ' '));
1984
+
1985
+ if (readable.length > 0) {
1986
+ const last = readable[readable.length - 1]!;
1987
+ const GENERIC = ['edit', 'new', 'create', 'view', 'show', 'index', 'detail', 'details'];
1988
+ if (GENERIC.includes(last.toLowerCase()) && readable.length > 1) {
1989
+ pathName = `${readable[readable.length - 2]} · ${last}`;
1990
+ } else {
1991
+ pathName = last;
1992
+ }
1993
+ } else if (allSegs.length > 0) {
1994
+ // All segments were IDs — use the last non-trivial one
1995
+ const lastSeg = allSegs[allSegs.length - 1]!;
1996
+ pathName = lastSeg.slice(0, 8).toUpperCase();
1997
+ }
1998
+
1999
+ // Append significant query params
2000
+ const SIG_PARAMS = ['product_cat', 'category', 'cat', 'type', 'tag', 'brand', 'filter', 'tab', 'section', 'view'];
2001
+ const paramSuffix = SIG_PARAMS
2002
+ .map((p) => { const v = u.searchParams.get(p); return v ? ` · ${v.replace(/[-_]/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())}` : ''; })
2003
+ .find(Boolean) ?? '';
2004
+ pathName = pathName + paramSuffix;
2005
+
2006
+ // Detect brand name from hostname for later comparisons
2007
+ brandName = hostname;
2008
+ } catch { /* fall through */ }
2009
+
2010
+ // 1. Document title — use only if it has a page-specific segment
2011
+ try {
2012
+ const title = await page.title();
2013
+ if (title && title.length > 0 && title.length < 120) {
2014
+ const parts = title.split(/\s*[|—\-–·]\s*/);
2015
+ // Store brand candidate (usually last segment)
2016
+ if (parts.length > 1) brandName = (parts[parts.length - 1] ?? brandName).trim().toLowerCase();
2017
+
2018
+ if (parts.length > 1) {
2019
+ // "Dashboard | ZeTA" → "Dashboard"
2020
+ const candidate = parts[0].trim();
2021
+ if (candidate.length > 2 && !/^\d[\d\s]*$/.test(candidate)) return candidate;
2022
+ }
2023
+ // Single-segment title — only use if multi-word and not brand-name-like
2024
+ const candidate = parts[0].trim();
2025
+ const isBrand = candidate.split(/\s+/).length === 1 &&
2026
+ (candidate.toLowerCase().includes(brandName) || candidate.toLowerCase() === brandName);
2027
+ if (!isBrand && candidate.split(/\s+/).length > 1 && !/^\d[\d\s]*$/.test(candidate)) {
2028
+ return candidate;
2029
+ }
2030
+ }
2031
+ } catch { /* fall through */ }
2032
+
2033
+ // 2. Active nav item — SPAs set aria-current="page" on the active route link
2034
+ try {
2035
+ const activeNav = await page.locator('[aria-current="page"]').first().textContent({ timeout: 2000 }).catch(() => null);
2036
+ if (activeNav) {
2037
+ const t = activeNav.trim();
2038
+ if (t.length > 1 && t.length < 60 && t.toLowerCase() !== brandName) return t;
2039
+ }
2040
+ } catch { /* fall through */ }
2041
+
2042
+ // 3. Page-specific heading — skip if it's just the brand name
2043
+ try {
2044
+ const selectors = [
2045
+ 'main h1', '[role="main"] h1', '.page-title', '.page-header h1', '.content h1',
2046
+ 'main h2', '[role="main"] h2', '.page-header h2',
2047
+ ];
2048
+ for (const sel of selectors) {
2049
+ const h = await page.locator(sel).first().textContent({ timeout: 2000 }).catch(() => null);
2050
+ if (!h) continue;
2051
+ const t = h.trim();
2052
+ if (t.length < 2 || t.length > 80) continue;
2053
+ // Skip if it's the brand name (exact or contains hostname)
2054
+ if (t.toLowerCase() === brandName) continue;
2055
+ if (brandName && t.toLowerCase().replace(/\s/g, '').includes(brandName.replace(/\s/g, ''))) continue;
2056
+ return t;
2057
+ }
2058
+ } catch { /* fall through */ }
2059
+
2060
+ // Safety: if the only candidate is the brand/hostname, fall back to URL path.
2061
+ try {
2062
+ const hostname = new URL(url).hostname.replace(/^www\./, '').split('.')[0]!.toLowerCase();
2063
+ if (pathName !== 'Home' || name.toLowerCase() === hostname || name.toLowerCase().replace(/\s/g, '') === hostname) {
2064
+ // name is still brand-name-equivalent — use URL-derived pathName
2065
+ if (name.toLowerCase() === hostname || name.toLowerCase().replace(/\s/g, '') === hostname) {
2066
+ return pathName;
2067
+ }
2068
+ }
2069
+ } catch { /* ignore */ }
2070
+
2071
+ return pathName;
2072
+ })();
2073
+ const screen = await AppBrain.saveScreen(opts.tenantId, opts.projectId, name, url, opts.jobId, discoveryMethodFor(url));
2074
+ visited.set(canonicalize(url), screen.id);
2075
+
2076
+ const shotPath = path.join(opts.screenshotDir ?? '/tmp', `${screen.id}-desktop-v${screen.version}.png`);
2077
+
2078
+ // Wait for skeleton loaders / aria-busy spinners to resolve before screenshotting and analysis.
2079
+ // Stagehand V3 proxy does NOT support page.waitForFunction — use page.evaluate polling instead.
2080
+ await (async () => {
2081
+ const deadline = Date.now() + 4_000;
2082
+ while (Date.now() < deadline) {
2083
+ const ready = await page.evaluate(() => {
2084
+ const busy = document.querySelectorAll('[aria-busy="true"], [data-loading="true"]');
2085
+ const skeletons = document.querySelectorAll('.skeleton, [class*="skeleton"], [class*="Skeleton"]');
2086
+ return busy.length === 0 && skeletons.length === 0;
2087
+ }).catch(() => true); // if evaluate fails, assume ready
2088
+ if (ready) break;
2089
+ await new Promise(r => setTimeout(r, 200));
2090
+ }
2091
+ })();
2092
+
2093
+ // Fire-and-forget per-page analyses — non-blocking, failures silently swallowed.
2094
+ // screen is now declared; page still has current page state and is fully hydrated.
2095
+ void (async () => {
2096
+ const screenId = screen.id;
2097
+ const [seoR, contrastR, flagsR, langR, formsR] = await Promise.allSettled([
2098
+ checkSeo(page, url),
2099
+ auditColorContrast(page, url),
2100
+ detectFeatureFlags(page, url),
2101
+ detectLanguage(page, url),
2102
+ detectForms(page),
2103
+ ]);
2104
+ const base = { screenId, jobId: opts.jobId };
2105
+ if (seoR.status === 'fulfilled')
2106
+ AppBrain.saveCrawlEvidence(opts.tenantId, opts.projectId, { ...base, type: 'SEO_CHECK', payload: seoR.value }).catch(() => { });
2107
+ if (contrastR.status === 'fulfilled' && (contrastR.value as any).issues?.length)
2108
+ AppBrain.saveCrawlEvidence(opts.tenantId, opts.projectId, { ...base, type: 'COLOR_CONTRAST', payload: contrastR.value }).catch(() => { });
2109
+ if (flagsR.status === 'fulfilled' && (flagsR.value as any).flags?.length)
2110
+ AppBrain.saveCrawlEvidence(opts.tenantId, opts.projectId, { ...base, type: 'FEATURE_FLAGS', payload: flagsR.value }).catch(() => { });
2111
+ if (langR.status === 'fulfilled')
2112
+ AppBrain.saveCrawlEvidence(opts.tenantId, opts.projectId, { ...base, type: 'LANGUAGE_DETECTION', payload: langR.value }).catch(() => { });
2113
+ if (formsR.status === 'fulfilled' && Array.isArray(formsR.value) && formsR.value.length)
2114
+ AppBrain.saveCrawlEvidence(opts.tenantId, opts.projectId, { ...base, type: 'FORMS_DETECTED', payload: { forms: formsR.value } }).catch(() => { });
2115
+ })();
2116
+
2117
+ // Freeze all CSS animations/transitions so screenshots are stable across runs
2118
+ try { await page.addStyleTag({ content: '*, *::before, *::after { animation-duration: 0s !important; animation-delay: 0s !important; transition-duration: 0s !important; transition-delay: 0s !important; }' }); } catch { /* Stagehand proxy may not expose addStyleTag */ }
2119
+
2120
+ // Replay-first: check for a cached link-discovery recipe before calling AI Stagehand
2121
+ const canonUrl = canonicalize(url);
2122
+ const linkRecipe = await AppBrain.getActionRecipe(
2123
+ opts.tenantId, opts.projectId, new URL(url).hostname, `link-discovery:${canonUrl}`
2124
+ ).catch(() => null);
2125
+ const cachedLinks: string[] | null = (linkRecipe && linkRecipe.successRate >= 0.7)
2126
+ ? ((linkRecipe.steps as any[])
2127
+ .map((s: any) => s.href)
2128
+ .filter((href: string | undefined): href is string => !!href && isUsefulNavigationUrl(href, origin)))
2129
+ : null;
2130
+ if (cachedLinks) {
2131
+ logger.info({ url, cachedCount: cachedLinks.length }, '[crawler] link-discovery: using cached recipe — AI will still run and merge');
2132
+ }
2133
+
2134
+ // Emit progress now — navigation is done and the page is stable.
2135
+ // This fires before the 30-90s parallel screenshot+AI phase, preventing a silent gap in the UI.
2136
+ await onProgress?.(20, `Crawling ${visited.size} of ~${visited.size + queue.length} · ${(() => { try { return new URL(url).pathname || '/'; } catch { return '/'; } })()}`);
2137
+
2138
+ // P3a: Async content detection — get raw DOM element count before AI extraction.
2139
+ // The AI extraction takes 30-90s; any async-loaded content will be visible after it completes.
2140
+ // Compare pre vs post count to detect dashboards that populate after initial load.
2141
+ const preDomCount = await page.evaluate(() =>
2142
+ document.querySelectorAll('button,a,input,select,textarea,[role="button"],[onclick],[aria-label]').length
2143
+ ).catch(() => 0);
2144
+
2145
+ await waitForScreenshotReady(page);
2146
+
2147
+ // Parallelize: screenshot + element extraction + link discovery + AT capture (all work on current page state)
2148
+ const [, elements, nextLinks, atElements] = await Promise.all([
2149
+ page.screenshot({ path: shotPath, fullPage: true })
2150
+ .then(() => AppBrain.saveScreenshot(opts.tenantId, opts.projectId, screen.id, shotPath, screen.version, 'DESKTOP')),
2151
+ extractElements(stagehand, page, elementLLM ?? undefined).catch(() => [] as Awaited<ReturnType<typeof extractElements>>),
2152
+ // Always run AI link discovery (even when cache exists) — cache is merged in, not used as a replacement.
2153
+ // Skipping AI when cache exists was a bug: stale cache from broken crawls permanently blocked discovery.
2154
+ discoverLinks(stagehand, origin, page, linkLLM ?? undefined, aiSkipCount > 0)
2155
+ .then((freshLinks) => {
2156
+ if (aiSkipCount > 0) aiSkipCount--;
2157
+ aiErrorStreak = 0;
2158
+ const merged = (cachedLinks ? Array.from(new Set([...cachedLinks, ...freshLinks])) : freshLinks)
2159
+ .filter((href) => isUsefulNavigationUrl(href, origin));
2160
+ logger.info({ url, domLinks: freshLinks.length, cached: cachedLinks?.length ?? 0, merged: merged.length }, '[crawler] link-discovery: result');
2161
+ // Always persist fresh recipe so stale cache gets overwritten with new AI discoveries
2162
+ if (merged.length > 0) {
2163
+ AppBrain.saveActionRecipe(opts.tenantId, opts.projectId, {
2164
+ domain: new URL(url).hostname,
2165
+ purpose: `link-discovery:${canonUrl}`,
2166
+ fingerprint: sha256(canonUrl),
2167
+ steps: merged.map((href) => ({ action: 'link', href })),
2168
+ successRate: 0.8,
2169
+ }).catch(() => { });
2170
+ }
2171
+ return merged;
2172
+ })
2173
+ .catch((err: any) => {
2174
+ const fallback: string[] = err?.domLinks ?? [];
2175
+ if (err?.isRateLimit) {
2176
+ aiErrorStreak++;
2177
+ if (aiErrorStreak >= 3) { aiSkipCount = 10; logger.warn('[crawler] 3 consecutive AI rate limits — skipping AI link discovery for next 10 screens'); }
2178
+ if (!aiRateLimitWarned) {
2179
+ aiRateLimitWarned = true;
2180
+ onProgress?.(20, 'AI quota exhausted — falling back to DOM-only link discovery').catch(() => { });
2181
+ }
2182
+ }
2183
+ return fallback;
2184
+ }),
2185
+ readScreen(page).catch(() => [] as Awaited<ReturnType<typeof readScreen>>),
2186
+ ]);
2187
+ logger.info({ url, screenId: screen.id, version: screen.version, elements: elements.length, links: nextLinks.length, atSample: atElements.slice(0, 3).map((a: any) => a.label) }, '[crawler] screen captured');
2188
+ console.log('[crawler] AT:', JSON.stringify(atElements.slice(0, 5)));
2189
+
2190
+ // Capture DOM snapshot for structural analysis, and extract Markdown/text
2191
+ // content from the same DOM (crawl4AI-parity text output) — no extra fetch.
2192
+ try {
2193
+ const domContent = await capturePageHtml(page);
2194
+ if (!domContent) {
2195
+ logger.warn({ url, screenId: screen.id }, '[crawler] markdown extraction skipped: DOM snapshot unavailable');
2196
+ } else {
2197
+ const domPath = shotPath.replace(/\.(png|jpg|jpeg)$/i, '.dom.html');
2198
+ await fs.writeFile(domPath, domContent, 'utf8');
2199
+ AppBrain.updateScreenDomSnapshot(opts.tenantId, screen.id, domPath).catch(() => { });
2200
+
2201
+ // Supplement markdown with iFrame and Shadow DOM content
2202
+ const [iframeContent, shadowContent] = await Promise.all([
2203
+ extractIframeContent(page).catch(() => []),
2204
+ pierceShadowDom(page).catch(() => ''),
2205
+ ]);
2206
+ const supplemental = [
2207
+ ...iframeContent.map((f: any) => f.markdown).filter(Boolean),
2208
+ shadowContent,
2209
+ ].join('\n\n').trim();
2210
+
2211
+ const extracted = extractMarkdown(
2212
+ supplemental ? domContent + '\n<!-- iframe/shadow -->\n' + supplemental.slice(0, 3_000) : domContent,
2213
+ url
2214
+ );
2215
+ let fitMarkdown: string | undefined;
2216
+ if (extracted) {
2217
+ const mdPath = shotPath.replace(/\.(png|jpg|jpeg)$/i, '.md');
2218
+ fitMarkdown = buildFitMarkdown(extracted.markdown, [
2219
+ ...domainProfile.importantScreenPatterns,
2220
+ ...domainProfile.workflowGoals,
2221
+ ...(opts.bestFirstKeywords ?? []),
2222
+ ]);
2223
+ const { text: scrubbed, scrubbed: hadPii } = scrubPii(extracted.markdown);
2224
+ if (hadPii) logger.info({ url }, '[crawler] PII detected and redacted from markdown');
2225
+ const dedupResult = deduplicator.check(url, scrubbed);
2226
+ if (dedupResult.isDuplicate) {
2227
+ logger.info({ url, similarUrl: dedupResult.similarUrl, score: dedupResult.similarityScore }, '[crawler] near-duplicate page skipped');
2228
+ }
2229
+ await fs.writeFile(mdPath, scrubbed, 'utf8');
2230
+
2231
+ // Generate a one-sentence AI summary from the markdown — fire-and-forget
2232
+ const summaryPromise = (async () => {
2233
+ try {
2234
+ const llmCfg = await getTaskAgentLLMConfig(opts.tenantId, 'crawl').catch(() => undefined);
2235
+ const snippet = extracted.markdown.slice(0, 1200);
2236
+ const { text, usage } = await askTracked(
2237
+ 'You are a QA analyst. Given page content, write ONE crisp sentence describing what this screen does or shows. Be specific. No preamble.',
2238
+ `Screen: "${name}"\nURL: ${url}\n\nContent:\n${snippet}`,
2239
+ llmCfg,
2240
+ 80,
2241
+ );
2242
+ if (usage) {
2243
+ recordLLMCall({
2244
+ tenantId: opts.tenantId,
2245
+ projectId: opts.projectId,
2246
+ jobId: opts.jobId,
2247
+ agentType: 'crawl_summary',
2248
+ label: `page_summary:${name}`,
2249
+ usage,
2250
+ }).catch(() => { });
2251
+ }
2252
+ return text.trim().replace(/^["']|["']$/g, '');
2253
+ } catch { return undefined; }
2254
+ })();
2255
+
2256
+ AppBrain.updateScreenMarkdown(opts.tenantId, opts.projectId, screen.id, {
2257
+ markdownPath: mdPath,
2258
+ markdownMethod: extracted.method,
2259
+ markdownWordCount: extracted.wordCount,
2260
+ markdown: fitMarkdown ?? extracted.markdown,
2261
+ heading: extracted.title || name,
2262
+ }).catch((err: any) => console.warn('[crawler] markdown extraction save failed:', err));
2263
+
2264
+ summaryPromise.then((aiSummary) => {
2265
+ if (aiSummary) AppBrain.updateScreenAiSummary(opts.tenantId, screen.id, aiSummary).catch(() => { });
2266
+ });
2267
+ }
2268
+
2269
+ const structuredJson = summarizeStructuredElements(atElements);
2270
+ const artifact = buildCrawlArtifactEnvelope({
2271
+ tenantId: opts.tenantId,
2272
+ projectId: opts.projectId,
2273
+ jobId: opts.jobId,
2274
+ url,
2275
+ canonicalUrl: canonicalize(url),
2276
+ screenId: screen.id,
2277
+ screenshotPath: shotPath,
2278
+ domPath,
2279
+ domContent,
2280
+ markdown: extracted?.markdown,
2281
+ fitMarkdown,
2282
+ accessibilityElements: atElements,
2283
+ structuredJson,
2284
+ evidence: [
2285
+ { type: 'domain_profile', payload: { industry: domainProfile.industry, confidence: domainProfile.confidence } },
2286
+ { type: 'engine_route', payload: routeDecision },
2287
+ { type: 'screen_fingerprints', payload: { urlCanonicalHash: sha256(canonicalize(url)) } },
2288
+ ],
2289
+ });
2290
+ AppBrain.saveCrawlEvidence(opts.tenantId, opts.projectId, {
2291
+ screenId: screen.id,
2292
+ jobId: opts.jobId,
2293
+ type: 'SCREEN_ARTIFACT_ENVELOPE',
2294
+ payload: artifact,
2295
+ }).catch((err: any) => console.warn('[crawler] artifact evidence save failed:', err?.message ?? err));
2296
+ }
2297
+ } catch (err: any) {
2298
+ logger.warn({ url, screenId: screen.id, err: String(err?.message ?? err) }, '[crawler] markdown extraction failed');
2299
+ }
2300
+
2301
+ // Persist a11y score + individual violations — fire-and-forget, non-fatal
2302
+ if (a11yScore !== null) {
2303
+ AppBrain.updateScreenA11y(opts.tenantId, screen.id, { a11yScore })
2304
+ .catch((err: any) => console.warn('[crawler] a11y score update failed:', err));
2305
+ }
2306
+ if (a11yResult !== null && a11yResult.violations.length > 0) {
2307
+ AppBrain.saveA11yViolations(opts.tenantId, opts.projectId, screen.id, a11yResult.violations, opts.jobId)
2308
+ .catch((err: any) => console.warn('[crawler] a11y violations save failed:', err));
2309
+ }
2310
+
2311
+ // DOM hash was registered before saveScreen — no action needed here.
2312
+
2313
+ // Build a role→selectorHint lookup from AT data so fingerprints can include tag hints
2314
+ const atByRole = new Map<string, string>();
2315
+ for (const at of atElements) {
2316
+ if (!atByRole.has(at.role)) atByRole.set(at.role, at.selectorHint);
2317
+ }
2318
+
2319
+ // Auto-create DESKTOP visual baseline on first crawl (no-op on recrawls)
2320
+ AppBrain.autoCreateVisualBaseline(opts.tenantId, opts.projectId, screen.id, 'DESKTOP', shotPath, gitInfo.branch, gitInfo.commit || undefined)
2321
+ .catch(() => { });
2322
+
2323
+ // Phase 2: compute element hash for stale-test detection
2324
+ const newElementHash = computeElementHash(elements);
2325
+ AppBrain.updateElementHash(opts.tenantId, screen.id, newElementHash, (screen as any).elementHash ?? null)
2326
+ .catch((err: any) => console.warn('[crawler] element hash update failed:', err));
2327
+
2328
+ // Phase 3: perceptual screenshot diff — non-blocking
2329
+ if (screen.version > 1) {
2330
+ (async () => {
2331
+ try {
2332
+ const prevShot = await AppBrain.getPreviousScreenshotPath(opts.tenantId, screen.id, screen.version - 1);
2333
+ if (prevShot) {
2334
+ const { screenshotDiffPercent } = await import('./phash.js');
2335
+ const { pHashDistance, changeLevel } = await screenshotDiffPercent(prevShot, shotPath);
2336
+ await AppBrain.saveScreenDiff(opts.tenantId, opts.projectId, screen.id, screen.version - 1, screen.version, pHashDistance, changeLevel);
2337
+ }
2338
+ } catch (err) {
2339
+ console.warn('[crawler] pHash diff skipped:', err);
2340
+ }
2341
+ })();
2342
+ }
2343
+
2344
+ // VisualDiff pipeline — compare against approved baseline (not previous screenshot)
2345
+ if (screen.version > 1) {
2346
+ (async () => {
2347
+ try {
2348
+ const baseline = await AppBrain.getVisualBaselineForDiff(opts.tenantId, screen.id, 'DESKTOP', gitInfo.branch);
2349
+ if (baseline && baseline.storagePath !== shotPath) {
2350
+ const { screenshotDiffPercent, gridLayoutDiff } = await import('./phash.js');
2351
+ const [{ pHashDistance, changeLevel }, layoutChanges, elementShifts] = await Promise.all([
2352
+ screenshotDiffPercent(baseline.storagePath, shotPath, baseline.ignoredRegions),
2353
+ gridLayoutDiff(baseline.storagePath, shotPath, baseline.ignoredRegions),
2354
+ AppBrain.computeElementLayoutDiff(opts.tenantId, screen.id, 'DESKTOP'),
2355
+ ]);
2356
+ let verdict = pHashVerdict(pHashDistance, changeLevel, [
2357
+ ...layoutChanges,
2358
+ ...elementShifts.map((s) => ({ location: 'element', severity: s.severity, description: s.description })),
2359
+ ]);
2360
+
2361
+ // pHash is a cheap, always-on filter; only spend an LLM call on the
2362
+ // screens it flags as worth a closer look. It also can't tell
2363
+ // meaningful UI changes apart from anti-aliasing/font-rendering noise,
2364
+ // which is exactly what Visual AI comparison exists to filter out.
2365
+ if (verdict.changeType === 'SIGNIFICANT' || verdict.changeType === 'CRITICAL') {
2366
+ try {
2367
+ const regions = baseline.ignoredRegions ?? [];
2368
+ // Same mask the pHash passes already applied — otherwise a
2369
+ // manually-masked region (timestamp, ad banner, live counter)
2370
+ // still reaches the AI verdict unmasked and can get flagged.
2371
+ const [baselineBuffer, newBuffer] = regions.length > 0
2372
+ ? await (async () => {
2373
+ const { applyMask } = await import('./phash.js');
2374
+ return Promise.all([applyMask(baseline.storagePath, regions), applyMask(shotPath, regions)]);
2375
+ })()
2376
+ : await Promise.all([
2377
+ fs.readFile(baseline.storagePath),
2378
+ fs.readFile(shotPath),
2379
+ ]);
2380
+ const aiResult = await aiVisualCompare({
2381
+ baselineImageBase64: baselineBuffer.toString('base64'),
2382
+ newImageBase64: newBuffer.toString('base64'),
2383
+ screenName: screen.name,
2384
+ screenPurpose: 'Application screen captured during automated crawl',
2385
+ tenantId: opts.tenantId,
2386
+ projectId: opts.projectId,
2387
+ });
2388
+ verdict = mergeAiVisualVerdict(verdict, aiResult, screen.name);
2389
+ } catch (aiErr) {
2390
+ logger.warn({ screenId: screen.id, err: String(aiErr) }, '[crawler] AI visual compare failed, keeping pHash-only verdict');
2391
+ }
2392
+ }
2393
+
2394
+ const { changeType, recommendation, changes: allChanges, bugReportDraft, aiSummary } = verdict;
2395
+ if (changeType !== 'NONE') {
2396
+ let newScreenshotCloudUrl: string | undefined;
2397
+ try {
2398
+ const { getTenantStorageConfig, uploadScreenshotToTenantStorage, buildArtifactKey } = await import('@detiq/app-brain');
2399
+ const storageCfg = await getTenantStorageConfig(opts.tenantId).catch(() => null);
2400
+ if (storageCfg) {
2401
+ const key = buildArtifactKey(opts.tenantId, opts.projectId, 'visual-diffs', `${screen.id}-v${screen.version}.png`, storageCfg.prefix);
2402
+ newScreenshotCloudUrl = await uploadScreenshotToTenantStorage(storageCfg, shotPath, key);
2403
+ }
2404
+ } catch { /* non-fatal — falls back to local path */ }
2405
+ await AppBrain.saveVisualDiff(opts.tenantId, {
2406
+ baselineId: baseline.id,
2407
+ newScreenshotPath: shotPath,
2408
+ newScreenshotCloudUrl,
2409
+ diffScore: pHashDistance,
2410
+ changeType,
2411
+ changes: allChanges,
2412
+ recommendation,
2413
+ bugReportDraft,
2414
+ aiSummary,
2415
+ });
2416
+ }
2417
+ }
2418
+ } catch (err: any) {
2419
+ const missing = String(err?.message ?? err).includes('Input file is missing');
2420
+ if (missing) {
2421
+ logger.warn({ screenId: screen.id, version: screen.version, err: String(err?.message ?? err) }, '[crawler] VisualDiff skipped — baseline screenshot not on disk (stored in cloud only). Run a full recrawl to regenerate baselines.');
2422
+ } else {
2423
+ console.warn('[crawler] VisualDiff pipeline failed:', err);
2424
+ }
2425
+ }
2426
+ })();
2427
+ }
2428
+
2429
+ const linkMappings = await extractLinkMappings(page, origin);
2430
+ for (const el of elements) {
2431
+ const saved = await AppBrain.saveElement(opts.tenantId, opts.projectId, screen.id, el.meaning, el.role, el.expectedData, el.notes, undefined, true, el.boundingRect, el.ariaState, el.parentLandmark);
2432
+ // Populate ElementFingerprint — fire-and-forget, failures are non-fatal
2433
+ const label = el.meaning;
2434
+ const role = el.role ?? 'unknown';
2435
+ const selectorHint = atByRole.get(role) ?? role;
2436
+ const selectors = [
2437
+ { type: 'role-label', value: `${role}:${label}`, confidence: 0.9 },
2438
+ { type: 'aria-label', value: label, confidence: 0.85 },
2439
+ { type: 'tag-hint', value: selectorHint, confidence: 0.6 },
2440
+ ];
2441
+ AppBrain.saveElementFingerprint(opts.tenantId, opts.projectId, saved.id, selectors).catch(console.warn);
2442
+ }
2443
+ for (const lm of linkMappings) {
2444
+ const safety = assessActionSafety(lm.label, domainProfile);
2445
+ if (!safety.allowed) {
2446
+ AppBrain.saveCrawlEvidence(opts.tenantId, opts.projectId, {
2447
+ screenId: screen.id,
2448
+ jobId: opts.jobId,
2449
+ type: 'ACTION_BLOCKED_BY_SAFETY_POLICY',
2450
+ payload: { label: lm.label, href: lm.href, decision: safety, profile: domainProfile.industry },
2451
+ }).catch(() => { });
2452
+ continue;
2453
+ }
2454
+ pendingTransitions.push({ fromScreenId: screen.id, elementMeaning: lm.label, targetUrl: lm.href });
2455
+ AppBrain.saveActionRecipe(opts.tenantId, opts.projectId, {
2456
+ domain: new URL(url).hostname,
2457
+ purpose: `navigate:${lm.label}`,
2458
+ fingerprint: sha256(`${canonicalize(url)}:${lm.label}:${canonicalize(lm.href)}`),
2459
+ steps: [{ action: 'click', label: lm.label, targetUrl: lm.href, fromUrl: url }],
2460
+ successRate: 0.5,
2461
+ }).catch(() => { });
2462
+
2463
+ if (!selective) {
2464
+ try {
2465
+ const targetPath = new URL(lm.href).pathname;
2466
+ const canonLmHref = canonicalize(lm.href);
2467
+ if (
2468
+ !SKIP_PATH.test(targetPath) &&
2469
+ isUsefulNavigationUrl(lm.href, origin) &&
2470
+ !queued.has(canonLmHref) &&
2471
+ !visited.has(canonLmHref) &&
2472
+ queued.size < maxScreens * 2
2473
+ ) {
2474
+ if (robotsCache && !await robotsCache.isAllowed(lm.href)) continue;
2475
+ queued.add(canonLmHref);
2476
+ queue.push(lm.href, { parentElements: elements.length });
2477
+ edges.push({ fromUrl: url, toUrl: lm.href });
2478
+ logger.info({ fromUrl: url, newUrl: lm.href, label: lm.label, queueSize: queue.length }, '[crawler] anchor mapping added to queue');
2479
+ }
2480
+ } catch { /* ignore invalid link mapping */ }
2481
+ }
2482
+ }
2483
+ if (!selective) {
2484
+ for (const href of nextLinks) {
2485
+ edges.push({ fromUrl: url, toUrl: href });
2486
+ // Skip WooCommerce/e-commerce action URLs — these are AJAX/form actions that render
2487
+ // the same page template with a compare widget; they are not distinct page routes.
2488
+ // They would all get queued (different `id` param = different canonical URL), navigated,
2489
+ // found "duplicate DOM", and burn through totalAttemptsThisSegment without any gain.
2490
+ try {
2491
+ const hrefParams = new URL(href).searchParams;
2492
+ const actionVal = hrefParams.get('action') ?? '';
2493
+ if (/^yith-|^wc-|^vc_|^elementor/.test(actionVal)) continue;
2494
+ } catch { /* invalid URL, let it fall through */ }
2495
+ const canonHref = canonicalize(href);
2496
+ if (!queued.has(canonHref) && !visited.has(canonHref) && queued.size < maxScreens * 2) {
2497
+ // Don't flood the queue with generated ID-like route segments.
2498
+ try {
2499
+ const hrefPath = new URL(href).pathname;
2500
+ if (isGeneratedDynamicPath(hrefPath)) continue;
2501
+ } catch { /* ignore invalid URLs */ }
2502
+ if (robotsCache && !await robotsCache.isAllowed(href)) continue;
2503
+ queued.add(canonHref);
2504
+ queue.push(href, { parentElements: elements.length }); // navigate to actual URL, dedup by canonical
2505
+ logger.info({ newUrl: href, queueSize: queue.length }, '[crawler] new URL added to queue');
2506
+ }
2507
+ }
2508
+ logger.info({ fromUrl: url, discovered: nextLinks.length, queueAfter: queue.length, visitedSoFar: visited.size }, '[crawler] link processing done');
2509
+
2510
+ // Harvest SPA routes captured via history.pushState/replaceState interception
2511
+ try {
2512
+ const jsRoutes = await page.evaluate(() => {
2513
+ const routes = (window as any).__zetaDiscoveredRoutes ?? [];
2514
+ (window as any).__zetaDiscoveredRoutes = [];
2515
+ return routes as string[];
2516
+ }).catch(() => [] as string[]);
2517
+ for (const route of jsRoutes) {
2518
+ try {
2519
+ const abs = route.startsWith('http') ? route : `${origin}${route.startsWith('/') ? '' : '/'}${route}`;
2520
+ const canonRoute = canonicalize(abs);
2521
+ if (!queued.has(canonRoute) && !visited.has(canonRoute)) {
2522
+ queued.add(canonRoute);
2523
+ queue.push(abs);
2524
+ logger.info({ route: abs }, '[crawler] history.pushState: discovered SPA route');
2525
+ }
2526
+ } catch { /* ignore invalid routes */ }
2527
+ }
2528
+ } catch { /* non-fatal */ }
2529
+
2530
+ // P3a: Post-extraction DOM count — detect async-loaded content.
2531
+ // Only check sparse pages (preDomCount < 25) where async widgets are likely.
2532
+ // If DOM grew significantly during AI extraction, re-visit later with networkidle.
2533
+ if (preDomCount < 25 && !revisitQueue.includes(url)) {
2534
+ try {
2535
+ const postDomCount = await page.evaluate(() =>
2536
+ document.querySelectorAll('button,a,input,select,textarea,[role="button"],[onclick],[aria-label]').length
2537
+ ).catch(() => 0);
2538
+ if (postDomCount > preDomCount * 1.3 + 4) {
2539
+ revisitQueue.push(url);
2540
+ logger.info({ url, preDomCount, postDomCount }, '[P3a] Async content detected — scheduled for re-visit');
2541
+ }
2542
+ } catch { /* non-fatal */ }
2543
+ }
2544
+
2545
+ // P2: Static JS router detection — scan bundle for declared client-side routes.
2546
+ // Only run on first 5 pages (bundle is the same across pages; scanning every page is wasteful).
2547
+ if (visited.size <= 5) {
2548
+ try {
2549
+ const jsDetectedRoutes = await detectJsRoutes(page, origin);
2550
+ for (const route of jsDetectedRoutes) {
2551
+ const canonRoute = canonicalize(route);
2552
+ if (isUsefulNavigationUrl(route, origin) && !queued.has(canonRoute) && !visited.has(canonRoute) && queued.size < maxScreens * 2) {
2553
+ queued.add(canonRoute);
2554
+ queue.push(route, { parentElements: elements.length });
2555
+ logger.info({ route }, '[P2] JS router: discovered route added to queue');
2556
+ }
2557
+ }
2558
+ } catch { /* non-fatal */ }
2559
+ }
2560
+
2561
+ // P4: Drain XHR-discovered page URL candidates (accumulated by network-observer callback).
2562
+ // These are relative paths extracted from JSON API response bodies.
2563
+ for (const relPath of [...xhrDiscoveredPaths]) {
2564
+ xhrDiscoveredPaths.delete(relPath);
2565
+ try {
2566
+ const abs = `${origin}${relPath}`;
2567
+ const canon = canonicalize(abs);
2568
+ if (isUsefulNavigationUrl(abs, origin) && !queued.has(canon) && !visited.has(canon) && queued.size < maxScreens * 2) {
2569
+ queued.add(canon);
2570
+ queue.push(abs);
2571
+ logger.info({ url: abs }, '[P4] XHR: page URL from JSON response added to queue');
2572
+ }
2573
+ } catch { /* ignore invalid paths */ }
2574
+ }
2575
+
2576
+ // Gap 5: Skyvern URL-discovery bootstrap for pages that defeat DOM link extraction.
2577
+ // Triggered per-page when: zero DOM links found + fewer than 5 elements (dead-end / canvas page).
2578
+ // Skyvern returns URLs it discovered → added to queue for ZeTa's own BFS crawl (not saved directly).
2579
+ // ZeTa does all actual crawling — Skyvern is only a URL-discovery override layer.
2580
+ if (nextLinks.length === 0 && elements.length < 5 && opts.skyvernConfig) {
2581
+ try {
2582
+ logger.info({ url }, 'Gap 5: thin page detected — asking Skyvern to discover navigation URLs');
2583
+ const skyvernNav = await skyvernNavigate(
2584
+ url,
2585
+ ['Find and return all navigation links, menu items, and page URLs accessible from this screen'],
2586
+ opts.skyvernConfig,
2587
+ );
2588
+ for (const skyvernUrl of skyvernNav.visitedUrls) {
2589
+ const canonSkyvernUrl = canonicalize(skyvernUrl);
2590
+ if (!queued.has(canonSkyvernUrl) && !visited.has(canonSkyvernUrl) && queued.size < maxScreens * 2) {
2591
+ logger.info({ url: skyvernUrl }, 'Gap 5: Skyvern-discovered URL added to ZeTa crawl queue');
2592
+ queued.add(canonSkyvernUrl);
2593
+ queue.push(skyvernUrl); // ZeTa will crawl this normally
2594
+ // Wire the KAG edge so the Skyvern-discovered page isn't flagged as an orphan
2595
+ pendingTransitions.push({ fromScreenId: screen.id, elementMeaning: 'Skyvern-discovered link', targetUrl: skyvernUrl });
2596
+ }
2597
+ }
2598
+ } catch { /* non-fatal — Skyvern unavailable */ }
2599
+ }
2600
+ }
2601
+
2602
+ if (rawPerf) {
2603
+ performanceMetrics.push({ url, screenName: name, ...rawPerf });
2604
+ }
2605
+
2606
+ const screenApiEndpoints = capturedApiEndpoints.slice(apiObservationStartIndex);
2607
+ if (screenApiEndpoints.length > 0) {
2608
+ AppBrain.saveApiEndpointObservations(opts.tenantId, opts.projectId, screen.id, screenApiEndpoints)
2609
+ .then(() => AppBrain.saveCrawlEvidence(opts.tenantId, opts.projectId, {
2610
+ screenId: screen.id,
2611
+ jobId: opts.jobId,
2612
+ type: 'API_ENDPOINT_OBSERVATIONS',
2613
+ payload: { endpoints: screenApiEndpoints },
2614
+ }))
2615
+ .catch((err: any) => console.warn('[crawler] API endpoint observation save failed:', err?.message ?? err));
2616
+ }
2617
+
2618
+ // Persist performance snapshot (web vitals + optional Lighthouse) — fire-and-forget
2619
+ if (opts.jobId && (rawPerf || process.env.ENABLE_LIGHTHOUSE)) {
2620
+ (async () => {
2621
+ try {
2622
+ const lhScores = await runLighthouse(url);
2623
+ await AppBrain.savePerformanceSnapshot(opts.tenantId, screen.id, opts.jobId!, {
2624
+ lcp: rawPerf?.lcp,
2625
+ fcp: rawPerf?.fcp,
2626
+ cls: rawPerf?.cls,
2627
+ ttfb: rawPerf?.ttfb,
2628
+ viewport: 'DESKTOP',
2629
+ ...(lhScores ? {
2630
+ lighthousePerf: lhScores.perf,
2631
+ lighthouseA11y: lhScores.a11y,
2632
+ lighthouseSEO: lhScores.seo,
2633
+ lighthouseBP: lhScores.bp,
2634
+ lighthouseReport: lhScores.report,
2635
+ } : {}),
2636
+ });
2637
+ } catch (perfErr) {
2638
+ console.warn('[crawler] savePerformanceSnapshot failed (non-fatal):', perfErr);
2639
+ }
2640
+ })();
2641
+ }
2642
+
2643
+ discovered.push({ url, screenId: screen.id, elements: elements.length, elementFacts: elements, screenshotPath: shotPath, version: screen.version });
2644
+ // Progress: 20% baseline + up to 60% across discovered screens.
2645
+ // Use high-water mark (maxSeenTotal) so % never goes backwards when new URLs are found.
2646
+ maxSeenTotal = Math.max(maxSeenTotal, visited.size + queue.length);
2647
+ const screenPct = Math.min(60, (visited.size / Math.max(1, maxSeenTotal)) * 60);
2648
+ await onProgress?.(20 + Math.round(screenPct), `Crawling screens (${visited.size} mapped, ${queue.length} pending)`);
2649
+ logger.info({ name, elements: elements.length }, 'Screen crawled');
2650
+ // Checkpoint every 10 successes so a failed job can be resumed from close to where it stopped
2651
+ if (++checkpointSuccessCount % 10 === 0) {
2652
+ await opts.onCheckpoint?.(
2653
+ Array.from(visited.keys()),
2654
+ queue.filter(u => !visited.has(canonicalize(u)))
2655
+ );
2656
+ }
2657
+ } catch (err: any) {
2658
+ logger.warn({ url, err: err?.message ?? String(err), stack: err?.stack?.split('\n').slice(0, 5).join(' | ') }, 'Screen crawl failed');
2659
+ discovered.push({ url, error: err?.message ?? String(err) });
2660
+ }
2661
+ }
2662
+
2663
+ // Discovery mode: return collected URLs and skip all post-crawl analysis phases.
2664
+ // Use visited.values() (original URLs) not .keys() (canonical forms with /{id} placeholders).
2665
+ if (opts.discoveryMode) {
2666
+ return {
2667
+ discoveredUrls: Array.from(visited.values()),
2668
+ screensFound: 0,
2669
+ screensErrored: 0,
2670
+ screensSkippedToNextSegment: 0,
2671
+ isPartial: false,
2672
+ discovered: [],
2673
+ apiEndpoints: [],
2674
+ performanceMetrics: [],
2675
+ pendingUrls: [],
2676
+ segmentIndex: segIdx,
2677
+ hasMoreSegments: false,
2678
+ domHashes: Array.from(seenDomHashes),
2679
+ };
2680
+ }
2681
+
2682
+ // Gap 5: Skyvern post-crawl top-up for canvas/non-DOM apps where total screens < 10.
2683
+ // Skyvern discovers URLs → ZeTa's own BFS crawl processes each one normally.
2684
+ // Skyvern is ONLY a URL-discovery layer here — it does NOT save screens directly.
2685
+ // Use only successfully-saved screens (screenId present), not total discovered including errors.
2686
+ const successfulScreenCount = discovered.filter(d => d.screenId).length;
2687
+ if (successfulScreenCount < 10 && opts.skyvernConfig) {
2688
+ await onProgress?.(78, 'Few screens found — using Skyvern to discover additional navigation URLs');
2689
+ const currentUrl = page.url();
2690
+ const skyvernNav = await skyvernNavigate(
2691
+ currentUrl,
2692
+ [
2693
+ 'Explore the main navigation and visit each top-level section',
2694
+ 'Click any sidebar or top menu items to discover sub-pages',
2695
+ ],
2696
+ opts.skyvernConfig,
2697
+ );
2698
+ const skyvernUrls = skyvernNav.visitedUrls.filter((u) => !visited.has(canonicalize(u)));
2699
+ if (skyvernUrls.length > 0) {
2700
+ logger.info({ count: skyvernUrls.length }, 'Gap 5: Skyvern found new URLs — processing with ZeTa native crawler');
2701
+ // Process each URL with ZeTa's own Playwright-based extraction
2702
+ for (const skyvernUrl of skyvernUrls) {
2703
+ if (visited.size >= maxScreens) break;
2704
+ try {
2705
+ await page.goto(skyvernUrl, { waitUntil: 'domcontentloaded', timeout: 20000 });
2706
+ await page.waitForTimeout(1500);
2707
+ const skyvernName = new URL(skyvernUrl).pathname.split('/').filter(Boolean).pop() ?? 'Screen';
2708
+ const screen = await AppBrain.saveScreen(opts.tenantId, opts.projectId, skyvernName, skyvernUrl, opts.jobId, 'SKYVERN_AI');
2709
+ const shotPath = `${opts.screenshotDir ?? '/tmp'}/${screen.id}-v${screen.version}.png`;
2710
+ await waitForScreenshotReady(page);
2711
+ await page.screenshot({ path: shotPath, fullPage: true });
2712
+ await AppBrain.saveScreenshot(opts.tenantId, opts.projectId, screen.id, shotPath, screen.version, 'DESKTOP');
2713
+ const [elems, links, linkMappings] = await Promise.all([
2714
+ extractElements(stagehand, page, elementLLM ?? undefined),
2715
+ discoverLinks(stagehand, origin, page, linkLLM ?? undefined, aiSkipCount > 0),
2716
+ extractLinkMappings(page, origin),
2717
+ ]);
2718
+ for (const el of elems) {
2719
+ await AppBrain.saveElement(opts.tenantId, opts.projectId, screen.id, el.meaning, el.role, el.expectedData, el.notes, undefined, true, el.boundingRect, el.ariaState, el.parentLandmark).catch(() => { });
2720
+ }
2721
+ visited.set(canonicalize(skyvernUrl), screen.id);
2722
+ discovered.push({ url: skyvernUrl, screenId: screen.id, elements: elems.length, version: screen.version });
2723
+ // Wire KAG element->screen transitions for this Skyvern-discovered page too —
2724
+ // without this, any page only ever reached via Skyvern always looks orphaned
2725
+ // (zero inbound edges) even when a real DOM link exists elsewhere pointing to it.
2726
+ for (const lm of linkMappings) {
2727
+ pendingTransitions.push({ fromScreenId: screen.id, elementMeaning: lm.label, targetUrl: lm.href });
2728
+ }
2729
+ // Also enqueue links Skyvern page found for further ZeTa crawling
2730
+ for (const href of links) {
2731
+ const c = canonicalize(href);
2732
+ if (isUsefulNavigationUrl(href, origin) && !queued.has(c) && !visited.has(c)) { queued.add(c); queue.push(href); }
2733
+ }
2734
+ } catch (err) {
2735
+ logger.warn({ url: skyvernUrl, err: String(err) }, 'Gap 5: ZeTa crawl of Skyvern URL failed');
2736
+ }
2737
+ }
2738
+ // Drain any newly queued URLs from Skyvern pages
2739
+ while (queue.length > 0 && visited.size < maxScreens) {
2740
+ const href = queue.shift()!;
2741
+ const canonHref = canonicalize(href);
2742
+ if (visited.has(canonHref)) continue;
2743
+ if (!isUsefulNavigationUrl(href, origin)) continue;
2744
+ try {
2745
+ await page.goto(href, { waitUntil: 'domcontentloaded', timeout: 20000 });
2746
+ await page.waitForTimeout(1000);
2747
+ const nm = new URL(href).pathname.split('/').filter(Boolean).pop() ?? 'Screen';
2748
+ const sc = await AppBrain.saveScreen(opts.tenantId, opts.projectId, nm, href, opts.jobId, 'SKYVERN_AI');
2749
+ const sp = `${opts.screenshotDir ?? '/tmp'}/${sc.id}-v${sc.version}.png`;
2750
+ await waitForScreenshotReady(page);
2751
+ await page.screenshot({ path: sp, fullPage: true });
2752
+ await AppBrain.saveScreenshot(opts.tenantId, opts.projectId, sc.id, sp, sc.version, 'DESKTOP');
2753
+ const [elems2, linkMappings2] = await Promise.all([
2754
+ extractElements(stagehand, page, elementLLM ?? undefined),
2755
+ extractLinkMappings(page, origin),
2756
+ ]);
2757
+ for (const el of elems2) {
2758
+ await AppBrain.saveElement(opts.tenantId, opts.projectId, sc.id, el.meaning, el.role, el.expectedData, el.notes, undefined, true, el.boundingRect, el.ariaState, el.parentLandmark).catch(() => { });
2759
+ }
2760
+ visited.set(canonHref, sc.id);
2761
+ discovered.push({ url: href, screenId: sc.id, elements: elems2.length, version: sc.version });
2762
+ for (const lm of linkMappings2) {
2763
+ pendingTransitions.push({ fromScreenId: sc.id, elementMeaning: lm.label, targetUrl: lm.href });
2764
+ }
2765
+ } catch { /* non-fatal */ }
2766
+ }
2767
+ }
2768
+ }
2769
+
2770
+ // P1: URL Pattern LLM Completion — post-crawl LLM prediction of undiscovered URLs.
2771
+ // Single LLM call predicts missing routes from discovered URL patterns, HEAD-validates each.
2772
+ // Saves URL_CANDIDATES evidence so the ActivityDrawer shows what was found.
2773
+ try {
2774
+ const { runUrlPatternCompletion } = await import('./url-pattern-completer.js');
2775
+ const llmCfgForPatterns = await getTaskAgentLLMConfig(opts.tenantId, 'crawl').catch(() => null);
2776
+ const urlPatternCandidates = await runUrlPatternCompletion(
2777
+ Array.from(visited.keys()),
2778
+ opts.appUrl,
2779
+ llmCfgForPatterns,
2780
+ );
2781
+ if (urlPatternCandidates.length > 0) {
2782
+ AppBrain.saveCrawlEvidence(opts.tenantId, opts.projectId, {
2783
+ jobId: opts.jobId,
2784
+ type: 'URL_CANDIDATES',
2785
+ payload: {
2786
+ candidates: urlPatternCandidates,
2787
+ hits: urlPatternCandidates.filter(c => c.status === 'hit').length,
2788
+ total: urlPatternCandidates.length,
2789
+ },
2790
+ }).catch(() => { });
2791
+ logger.info(
2792
+ { hits: urlPatternCandidates.filter(c => c.status === 'hit').length, total: urlPatternCandidates.length },
2793
+ '[P1] URL pattern completion saved to evidence',
2794
+ );
2795
+ }
2796
+ } catch (err) {
2797
+ logger.warn({ err: String(err) }, '[P1] URL pattern completion failed — non-fatal, continuing');
2798
+ }
2799
+
2800
+ // P3a: Async content re-visit — revisit pages where DOM grew significantly during extraction.
2801
+ // Uses networkidle wait + longer stability period to capture fully-loaded dashboard content.
2802
+ if (revisitQueue.length > 0) {
2803
+ await onProgress?.(79, `Re-visiting ${revisitQueue.length} page(s) with async content…`);
2804
+ logger.info({ count: revisitQueue.length }, '[P3a] Starting async content re-visit pass');
2805
+ for (const revisitUrl of revisitQueue.slice(0, 10)) { // cap at 10 re-visits
2806
+ const existingScreenId = visited.get(canonicalize(revisitUrl));
2807
+ if (!existingScreenId) continue;
2808
+ try {
2809
+ await page.goto(revisitUrl, { waitUntil: 'networkidle', timeout: 25_000 });
2810
+ await page.waitForTimeout(2_000);
2811
+ const shotPath2 = `${opts.screenshotDir ?? '/tmp'}/${existingScreenId}-revisit.png`;
2812
+ await waitForScreenshotReady(page);
2813
+ await page.screenshot({ path: shotPath2, fullPage: true });
2814
+ // Update screenshot with the more complete render
2815
+ await AppBrain.saveScreenshot(opts.tenantId, opts.projectId, existingScreenId, shotPath2, 1, 'DESKTOP').catch(() => { });
2816
+ // Re-extract elements with the async-loaded content now visible
2817
+ const revisitElements = await extractElements(stagehand, page, elementLLM ?? undefined).catch(() => []);
2818
+ for (const el of revisitElements) {
2819
+ await AppBrain.saveElement(opts.tenantId, opts.projectId, existingScreenId, el.meaning, el.role, el.expectedData, el.notes, undefined, true, el.boundingRect, el.ariaState, el.parentLandmark).catch(() => { });
2820
+ }
2821
+ // Harvest any new links revealed by async content
2822
+ const revisitLinks = await discoverLinks(stagehand, origin, page, linkLLM ?? undefined, false).catch(() => []);
2823
+ for (const href of revisitLinks) {
2824
+ const canon = canonicalize(href);
2825
+ if (isUsefulNavigationUrl(href, origin) && !queued.has(canon) && !visited.has(canon) && queued.size < maxScreens * 2) {
2826
+ queued.add(canon);
2827
+ queue.push(href);
2828
+ }
2829
+ }
2830
+ logger.info({ url: revisitUrl, elements: revisitElements.length, newLinks: revisitLinks.length }, '[P3a] Re-visit complete');
2831
+ } catch (err) {
2832
+ logger.warn({ url: revisitUrl, err: String(err) }, '[P3a] Re-visit failed — non-fatal');
2833
+ }
2834
+ }
2835
+ }
2836
+
2837
+ // P6: Gap Pass — LLM gap analysis on discovered screens to find structurally missing pages.
2838
+ // Runs after P3a re-visits so the gap analysis has the most complete screen list possible.
2839
+ // Confirmed gap URLs (HEAD=200) are visited directly with the existing browser.
2840
+ if (visited.size >= 3) {
2841
+ try {
2842
+ await onProgress?.(80, 'Running gap analysis…');
2843
+ const llmCfgForGap = await getTaskAgentLLMConfig(opts.tenantId, 'crawl').catch(() => null);
2844
+ const discoveredForGap = Array.from(visited.entries()).map(([url, screenId]) => {
2845
+ const d = discovered.find(dd => dd.screenId === screenId);
2846
+ return { url, name: d ? (new URL(url).pathname.split('/').filter(Boolean).pop() ?? 'screen') : undefined, elements: d?.elements };
2847
+ });
2848
+ const gapCandidates = await runGapAnalysis(discoveredForGap, opts.appUrl, llmCfgForGap);
2849
+ const confirmedGaps = gapCandidates.filter(g => g.confirmedUrl && !visited.has(canonicalize(g.confirmedUrl!)));
2850
+
2851
+ if (confirmedGaps.length > 0) {
2852
+ AppBrain.saveCrawlEvidence(opts.tenantId, opts.projectId, {
2853
+ jobId: opts.jobId,
2854
+ type: 'URL_CANDIDATES',
2855
+ payload: {
2856
+ source: 'gap-pass',
2857
+ candidates: gapCandidates.map(g => ({ url: g.confirmedUrl ?? g.urlPatterns[0], confidence: g.confidence, reason: g.rationale, status: g.confirmedUrl ? 'hit' : 'miss' })),
2858
+ hits: confirmedGaps.length,
2859
+ total: gapCandidates.length,
2860
+ },
2861
+ }).catch(() => { });
2862
+ logger.info({ confirmed: confirmedGaps.length, total: gapCandidates.length }, '[P6] Gap pass: visiting confirmed gap URLs');
2863
+
2864
+ // Visit each confirmed gap URL with the existing browser (depth=1, no link traversal)
2865
+ const gapLoginPattern = /\/(login|signin|sign-in|auth|log-in)(\/|$|\?)/i;
2866
+ for (const gap of confirmedGaps.slice(0, 8)) {
2867
+ const gapUrl = gap.confirmedUrl!;
2868
+ if (visited.size >= maxScreens) break;
2869
+ try {
2870
+ await page.goto(gapUrl, { waitUntil: 'domcontentloaded', timeout: 20_000 });
2871
+ // Skip if session expired — don't save a login-page screenshot as the gap result
2872
+ if (gapLoginPattern.test(page.url())) {
2873
+ logger.warn({ gapUrl, landed: page.url() }, '[P6] Gap URL redirected to login — skipping');
2874
+ continue;
2875
+ }
2876
+ await page.waitForTimeout(1_500);
2877
+ const gapName = gap.category;
2878
+ const gapScreen = await AppBrain.saveScreen(opts.tenantId, opts.projectId, gapName, gapUrl, opts.jobId, 'ENTRY_POINT');
2879
+ const gapShot = `${opts.screenshotDir ?? '/tmp'}/${gapScreen.id}-v${gapScreen.version}.png`;
2880
+ await waitForScreenshotReady(page);
2881
+ await page.screenshot({ path: gapShot, fullPage: true });
2882
+ await AppBrain.saveScreenshot(opts.tenantId, opts.projectId, gapScreen.id, gapShot, gapScreen.version, 'DESKTOP');
2883
+ const [gapElems, gapLinks, gapLinkMaps] = await Promise.all([
2884
+ extractElements(stagehand, page, elementLLM ?? undefined).catch(() => []),
2885
+ discoverLinks(stagehand, origin, page, linkLLM ?? undefined, false).catch(() => []),
2886
+ extractLinkMappings(page, origin).catch(() => []),
2887
+ ]);
2888
+ for (const el of gapElems) {
2889
+ await AppBrain.saveElement(opts.tenantId, opts.projectId, gapScreen.id, el.meaning, el.role, el.expectedData, el.notes, undefined, true, el.boundingRect, el.ariaState, el.parentLandmark).catch(() => { });
2890
+ }
2891
+ visited.set(canonicalize(gapUrl), gapScreen.id);
2892
+ discovered.push({ url: gapUrl, screenId: gapScreen.id, elements: gapElems.length, version: gapScreen.version });
2893
+ for (const lm of gapLinkMaps) {
2894
+ pendingTransitions.push({ fromScreenId: gapScreen.id, elementMeaning: lm.label, targetUrl: lm.href });
2895
+ }
2896
+ // Enqueue gap page's links for further crawl (within maxScreens budget)
2897
+ for (const href of gapLinks) {
2898
+ const canon = canonicalize(href);
2899
+ if (isUsefulNavigationUrl(href, origin) && !queued.has(canon) && !visited.has(canon) && queued.size < maxScreens * 2) {
2900
+ queued.add(canon); queue.push(href);
2901
+ }
2902
+ }
2903
+ logger.info({ url: gapUrl, elements: gapElems.length, category: gap.category }, '[P6] Gap URL captured');
2904
+ } catch (err) {
2905
+ logger.warn({ url: gapUrl, err: String(err) }, '[P6] Gap URL visit failed — non-fatal');
2906
+ }
2907
+ }
2908
+ } else {
2909
+ logger.info({ total: gapCandidates.length }, '[P6] Gap pass: no confirmed gaps found');
2910
+ }
2911
+ } catch (err) {
2912
+ logger.warn({ err: String(err) }, '[P6] Gap pass failed — non-fatal, continuing');
2913
+ }
2914
+ }
2915
+
2916
+ // After crawl: mark screens not re-discovered as PENDING_REMOVAL (non-destructive diff)
2917
+ // Only run if crawl actually found screens — 0 discovered means failed/aborted run, not evidence of removal
2918
+ if (!selective && visited.size > 0) {
2919
+ const discoveredUrls = Array.from(visited.keys());
2920
+ await AppBrain.markRemovedScreens(opts.tenantId, opts.projectId, discoveredUrls, opts.jobId);
2921
+ }
2922
+
2923
+ // Step 3: Save flows
2924
+ await onProgress?.(82, 'Saving app map');
2925
+ for (const edge of edges) {
2926
+ const fromId = visited.get(canonicalize(edge.fromUrl));
2927
+ const toId = visited.get(canonicalize(edge.toUrl));
2928
+ if (fromId && toId && fromId !== toId) {
2929
+ await AppBrain.saveFlow(opts.tenantId, opts.projectId, `${edge.fromUrl} -> ${edge.toUrl}`, fromId, toId);
2930
+ }
2931
+ }
2932
+
2933
+ // Step 4: Wire KAG element-level transitions
2934
+ // For each <a href> link captured from each screen, resolve the href to a visited screenId
2935
+ // and store transitionsToScreenId on the matching element.
2936
+ await onProgress?.(84, 'Wiring navigation graph (KAG)');
2937
+ // Secondary index: pathname → [canonicalUrl, ...] for subset-param fuzzy matching.
2938
+ // A link to /product?id=5 should resolve to a page visited at /product?id=5&session=abc
2939
+ // even though their canonical forms differ (session param not in the strip list).
2940
+ const visitedByPath = new Map<string, string[]>();
2941
+ for (const [canonUrl] of visited) {
2942
+ try {
2943
+ const p = new URL(canonUrl).pathname;
2944
+ const arr = visitedByPath.get(p);
2945
+ if (arr) arr.push(canonUrl); else visitedByPath.set(p, [canonUrl]);
2946
+ } catch { /* invalid canonical — skip */ }
2947
+ }
2948
+ for (const pt of pendingTransitions) {
2949
+ let toScreenId = visited.get(canonicalize(pt.targetUrl));
2950
+ if (!toScreenId) {
2951
+ // Fuzzy fallback: find a visited URL at the same pathname whose params are a superset
2952
+ // of the link's params (handles extra session/tracking params on the visited side).
2953
+ try {
2954
+ const targetCanon = new URL(canonicalize(pt.targetUrl));
2955
+ const candidates = visitedByPath.get(targetCanon.pathname) ?? [];
2956
+ for (const cand of candidates) {
2957
+ const candUrl = new URL(cand);
2958
+ let allMatch = true;
2959
+ for (const [k, v] of targetCanon.searchParams) {
2960
+ if (candUrl.searchParams.get(k) !== v) { allMatch = false; break; }
2961
+ }
2962
+ if (allMatch) { toScreenId = visited.get(cand); break; }
2963
+ }
2964
+ } catch { /* non-fatal */ }
2965
+ }
2966
+ if (toScreenId && toScreenId !== pt.fromScreenId) {
2967
+ await AppBrain.setElementTransition(opts.tenantId, pt.fromScreenId, pt.elementMeaning, toScreenId).catch(() => { });
2968
+ await AppBrain.saveScreenTransition(opts.tenantId, opts.projectId, {
2969
+ fromScreenId: pt.fromScreenId,
2970
+ toScreenId,
2971
+ action: {
2972
+ type: 'click',
2973
+ label: pt.elementMeaning,
2974
+ targetUrl: pt.targetUrl,
2975
+ source: 'crawl-link-mapping',
2976
+ },
2977
+ confidence: 0.8,
2978
+ jobId: opts.jobId,
2979
+ }).catch(() => { });
2980
+ }
2981
+ }
2982
+ const transitionCount = pendingTransitions.filter(pt => {
2983
+ if (visited.get(canonicalize(pt.targetUrl))) return true;
2984
+ try {
2985
+ const tc = new URL(canonicalize(pt.targetUrl));
2986
+ return (visitedByPath.get(tc.pathname) ?? []).some(cand => {
2987
+ const cu = new URL(cand);
2988
+ for (const [k, v] of tc.searchParams) { if (cu.searchParams.get(k) !== v) return false; }
2989
+ return true;
2990
+ });
2991
+ } catch { return false; }
2992
+ }).length;
2993
+ logger.info({ transitionCount }, 'KAG: wired element→screen transitions');
2994
+ AppBrain.saveCrawlEvidence(opts.tenantId, opts.projectId, {
2995
+ jobId: opts.jobId,
2996
+ type: 'WORKFLOW_GRAPH_SUMMARY',
2997
+ payload: {
2998
+ transitionCount,
2999
+ screenCount: visited.size,
3000
+ profile: domainProfile.industry,
3001
+ },
3002
+ }).catch(() => { });
3003
+
3004
+ if (capturedApiEndpoints.length > 0) {
3005
+ logger.info({ count: capturedApiEndpoints.length }, 'Captured unique API endpoint patterns');
3006
+ }
3007
+
3008
+ if (visited.size >= 5) {
3009
+ await onProgress?.(90, `App map saved · ${visited.size} screens, ${edges.length} flows wired`);
3010
+ }
3011
+
3012
+ // P5: Orphan page auth re-verify — confirm session is still alive before tail-end viewport pass.
3013
+ // The main BFS can span many minutes; the session may have expired by the time we reach here.
3014
+ // If expired, attempt re-auth via Stagehand before proceeding. Only skip if re-auth also fails.
3015
+ let sessionAliveForViewports = true;
3016
+ try {
3017
+ await page.goto(opts.appUrl, { waitUntil: 'domcontentloaded', timeout: 15_000 });
3018
+ const sessionCheckUrl = page.url();
3019
+ const loginKeywords = /login|signin|sign-in|\/auth[/?#]/i;
3020
+ if (loginKeywords.test(sessionCheckUrl)) {
3021
+ logger.warn({ landed: sessionCheckUrl }, '[P5] Session expired before viewport pass — attempting re-auth');
3022
+ let reauthed = false;
3023
+ if (opts.credentials) {
3024
+ reauthed = await loginWithStagehand(
3025
+ stagehand, opts.credentials, opts.appUrl,
3026
+ { apiKey: opts.captchaSolverApiKey, provider: opts.captchaSolverProvider },
3027
+ { apiKey: opts.mailslurpApiKey, inboxId: opts.mailslurpInboxId },
3028
+ undefined,
3029
+ { tenantId: opts.tenantId, projectId: opts.projectId, jobId: opts.jobId },
3030
+ ).catch(() => false);
3031
+ }
3032
+ if (reauthed) {
3033
+ logger.info('[P5] Re-auth succeeded — proceeding with viewport pass');
3034
+ } else {
3035
+ sessionAliveForViewports = false;
3036
+ logger.warn({ landed: sessionCheckUrl }, '[P5] Re-auth failed — skipping mobile/tablet screenshots');
3037
+ AppBrain.saveCrawlEvidence(opts.tenantId, opts.projectId, {
3038
+ jobId: opts.jobId,
3039
+ type: 'AUTH_FAILURE',
3040
+ payload: {
3041
+ url: opts.appUrl,
3042
+ landed: sessionCheckUrl,
3043
+ reason: 'Session expired before mobile/tablet viewport pass and re-auth failed. Re-capture the session in project settings.',
3044
+ },
3045
+ }).catch(() => { });
3046
+ }
3047
+ } else {
3048
+ logger.info({ url: sessionCheckUrl }, '[P5] Session alive — proceeding with viewport pass');
3049
+ }
3050
+ } catch (err) {
3051
+ logger.warn({ err: String(err) }, '[P5] Session health check failed — skipping viewport pass to avoid bad screenshots');
3052
+ sessionAliveForViewports = false;
3053
+ }
3054
+
3055
+ // Step 5: Multi-viewport pass — screenshot + DOM element extraction at each device profile.
3056
+ // Only DESKTOP (the main crawl above) runs Stagehand AI navigation and link discovery.
3057
+ // Mobile/tablet viewports navigate to already-discovered screens and re-extract elements
3058
+ // with their viewport-specific bounding rects.
3059
+ //
3060
+ // IMPORTANT: We launch a fresh Playwright browser per viewport profile instead of calling
3061
+ // playwrightBrowser.newContext() on the CDP-connected instance. CDP-connected browsers
3062
+ // share the DevTools session with Stagehand and cannot reliably set device emulation
3063
+ // (isMobile, hasTouch, viewport, deviceScaleFactor) on new contexts — the flags are
3064
+ // silently ignored, so screenshots come out at desktop dimensions regardless of profile.
3065
+ const extraProfiles = (opts.deviceProfiles ?? []).filter((p) => p !== 'DESKTOP');
3066
+ if (sessionAliveForViewports && extraProfiles.length > 0 && discovered.length > 0) {
3067
+ const { devices, chromium: pwChromium } = await import('playwright');
3068
+ const DEVICE_MAP: Record<string, any> = {
3069
+ // Override viewport heights: Playwright's device descriptors use viewport-minus-browser-chrome
3070
+ // (659 for iPhone 15, ~980 for iPad Pro 11). Override to full logical screen dimensions so
3071
+ // screenshots match what users see on device and mobile-detection scripts pass.
3072
+ MOBILE_PORTRAIT: { ...devices['iPhone 15'], viewport: { width: 393, height: 852 } },
3073
+ TABLET: { ...devices['iPad Pro 11'], viewport: { width: 834, height: 1194 } },
3074
+ };
3075
+ // Export session cookies from the main crawl so mobile browser is already authenticated
3076
+ let mainStorageState: any;
3077
+ try {
3078
+ mainStorageState = await playwrightBrowser!.contexts()[0]?.storageState();
3079
+ } catch { /* non-fatal — mobile pass will proceed without auth */ }
3080
+
3081
+ for (const profile of extraProfiles) {
3082
+ const deviceConfig = DEVICE_MAP[profile];
3083
+ if (!deviceConfig) continue;
3084
+ await onProgress?.(95, `Capturing ${profile} screenshots + elements…`);
3085
+
3086
+ // Launch a dedicated browser for this viewport — no CDP sharing
3087
+ let mobileBrowser: import('playwright').Browser | null = null;
3088
+ try {
3089
+ mobileBrowser = await pwChromium.launch({
3090
+ headless: true,
3091
+ executablePath,
3092
+ args: chromeArgs,
3093
+ });
3094
+ const mobileCtx = await mobileBrowser.newContext({
3095
+ ...deviceConfig,
3096
+ ignoreHTTPSErrors: true,
3097
+ ...(mainStorageState ? { storageState: mainStorageState } : {}),
3098
+ });
3099
+ const mobilePage = await mobileCtx.newPage();
3100
+
3101
+ for (const disc of discovered) {
3102
+ if (!disc.screenId) continue;
3103
+ try {
3104
+ await mobilePage.goto(disc.url, { waitUntil: 'domcontentloaded', timeout: 20_000 });
3105
+ await mobilePage.waitForLoadState('networkidle', { timeout: 8_000 }).catch(() => { });
3106
+ await dismissOverlays(mobilePage);
3107
+ try { await mobilePage.addStyleTag({ content: '*, *::before, *::after { animation-duration: 0s !important; transition-duration: 0s !important; }' }); } catch { /* ignore */ }
3108
+ const shotPath = path.join(opts.screenshotDir ?? '/tmp', `${disc.screenId}-${profile.toLowerCase()}-v${disc.version}.png`);
3109
+ await waitForScreenshotReady(mobilePage);
3110
+ // Screenshot and DOM element extraction run in parallel
3111
+ const [, vpElements] = await Promise.all([
3112
+ mobilePage.screenshot({ path: shotPath, fullPage: true }),
3113
+ domFallbackElements(mobilePage),
3114
+ ]);
3115
+ await AppBrain.saveScreenshot(opts.tenantId, opts.projectId, disc.screenId, shotPath, disc.version!, profile);
3116
+ AppBrain.autoCreateVisualBaseline(opts.tenantId, opts.projectId, disc.screenId, profile, shotPath, gitInfo.branch, gitInfo.commit || undefined).catch(() => { });
3117
+ // Save viewport-specific element records (bounding rects differ per viewport)
3118
+ for (const el of vpElements) {
3119
+ AppBrain.saveElement(
3120
+ opts.tenantId, opts.projectId, disc.screenId,
3121
+ el.meaning, el.role, el.expectedData, el.notes,
3122
+ undefined, true, el.boundingRect, el.ariaState, el.parentLandmark,
3123
+ profile,
3124
+ ).catch(() => { });
3125
+ }
3126
+ } catch (err) {
3127
+ console.warn(`[crawler] ${profile} pass failed for ${disc.url}:`, err);
3128
+ }
3129
+ }
3130
+ await mobilePage.close().catch(() => { });
3131
+ await mobileCtx.close().catch(() => { });
3132
+ } catch (profileErr) {
3133
+ console.warn(`[crawler] ${profile} browser launch failed:`, profileErr);
3134
+ } finally {
3135
+ await mobileBrowser?.close().catch(() => { });
3136
+ }
3137
+ }
3138
+ }
3139
+
3140
+ // Step 6: Interactive probe — observe real dynamic behaviour (optional, ~30s extra)
3141
+ // Auto-enable for sparse crawls (< 30 screens): interaction-gated states are proportionally more common.
3142
+ const shouldRunInteractiveProbe = opts.interactive || discovered.filter(d => d.screenId).length < 30;
3143
+ if (shouldRunInteractiveProbe) {
3144
+ await onProgress?.(88, 'Running interactive probe (capturing real errors & modals)');
3145
+ const probeInputs = discovered
3146
+ .filter((d) => d.screenId)
3147
+ .map((d) => ({ url: d.url, screenId: d.screenId!, elements: d.elementFacts ?? [] }));
3148
+ await probeAllScreens(page, opts.tenantId, opts.projectId, probeInputs, onProgress);
3149
+ }
3150
+
3151
+ await onProgress?.(97, 'Running post-crawl analysis');
3152
+
3153
+ // ── Per-crawl summaries (fire-and-forget, non-blocking) ────────────────
3154
+ const successScreens = discovered.filter(d => !!d.screenId);
3155
+ ; (async () => {
3156
+ try {
3157
+ // 1. Sitemap XML
3158
+ if (successScreens.length > 0) {
3159
+ const sitemapUrls = screensToSitemapUrls(successScreens.map(d => ({ url: d.url })));
3160
+ const sitemapPath = path.join(opts.screenshotDir ?? '/tmp', 'sitemap.xml');
3161
+ await writeSitemap(sitemapUrls, sitemapPath);
3162
+ AppBrain.saveCrawlEvidence(opts.tenantId, opts.projectId, {
3163
+ jobId: opts.jobId, type: 'SITEMAP_WRITTEN', payload: { path: sitemapPath, urlCount: sitemapUrls.length },
3164
+ }).catch(() => { });
3165
+ }
3166
+
3167
+ // 2. OpenAPI spec from captured API endpoints
3168
+ if (capturedApiEndpoints.length > 0) {
3169
+ const spec = generateOpenApiSpec(capturedApiEndpoints, { title: `${opts.projectId} API`, version: '1.0.0' });
3170
+ const yamlStr = specToYaml(spec);
3171
+ const specPath = path.join(opts.screenshotDir ?? '/tmp', 'openapi.yaml');
3172
+ await fs.writeFile(specPath, yamlStr, 'utf8');
3173
+ AppBrain.saveCrawlEvidence(opts.tenantId, opts.projectId, {
3174
+ jobId: opts.jobId, type: 'OPENAPI_SPEC', payload: { path: specPath, endpointCount: capturedApiEndpoints.length },
3175
+ }).catch(() => { });
3176
+ }
3177
+
3178
+ // 3. Dead link summary
3179
+ const deadStats = deadLinkTracker.stats();
3180
+ if (deadStats.total > 0) {
3181
+ AppBrain.saveCrawlEvidence(opts.tenantId, opts.projectId, {
3182
+ jobId: opts.jobId, type: 'DEAD_LINKS', payload: { ...deadStats, links: deadLinkTracker.getDeadLinks() },
3183
+ }).catch(() => { });
3184
+ }
3185
+
3186
+ // 4. Redirect chain summary
3187
+ const redirectStats = redirectRegistry.stats();
3188
+ if (redirectStats.withRedirects > 0) {
3189
+ AppBrain.saveCrawlEvidence(opts.tenantId, opts.projectId, {
3190
+ jobId: opts.jobId, type: 'REDIRECT_CHAINS', payload: { ...redirectStats, problematic: redirectRegistry.getProblematic() },
3191
+ }).catch(() => { });
3192
+ }
3193
+
3194
+ // 5. Playwright test generation (first 5 screens as sample)
3195
+ if (successScreens.length > 0) {
3196
+ const sampleScreens = successScreens.slice(0, 5).map(d => ({
3197
+ id: d.screenId!, url: d.url, name: d.url,
3198
+ }));
3199
+ const playwrightScript = generatePlaywrightTest(sampleScreens);
3200
+ const testPath = path.join(opts.screenshotDir ?? '/tmp', 'generated.spec.ts');
3201
+ await fs.writeFile(testPath, playwrightScript, 'utf8');
3202
+ AppBrain.saveCrawlEvidence(opts.tenantId, opts.projectId, {
3203
+ jobId: opts.jobId, type: 'PLAYWRIGHT_TEST_GENERATED', payload: { path: testPath, screenCount: sampleScreens.length },
3204
+ }).catch(() => { });
3205
+ }
3206
+
3207
+ // 6. Webhook notification (if configured on project)
3208
+ const webhookUrl = (opts as any).webhookUrl;
3209
+ if (webhookUrl) {
3210
+ const notifier = new WebhookNotifier({ url: webhookUrl, secret: (opts as any).webhookSecret });
3211
+ await notifier.notify('crawl.completed', opts.jobId ?? '', opts.projectId, opts.tenantId, {
3212
+ screensFound: successScreens.length,
3213
+ apiEndpoints: capturedApiEndpoints.length,
3214
+ deadLinks: deadLinkTracker.stats().total,
3215
+ }).catch(() => { });
3216
+ }
3217
+ } catch { /* post-crawl analysis is non-fatal */ }
3218
+ })();
3219
+
3220
+ await onProgress?.(98, 'Finalizing');
3221
+ // Remaining queue = URLs discovered but not yet crawled due to segment limit
3222
+ const pendingUrls = queue.filter((u) => !visited.has(canonicalize(u)));
3223
+ const hasMoreSegments = pendingUrls.length > 0;
3224
+ const screensSucceeded = discovered.filter(d => !!d.screenId).length;
3225
+ const screensErrored = discovered.filter(d => !!d.error && !d.screenId).length;
3226
+
3227
+ return {
3228
+ screensFound: screensSucceeded, // successful captures only (not errors)
3229
+ screensErrored, // pages visited but failed to capture
3230
+ screensSkippedToNextSegment: pendingUrls.length,
3231
+ isPartial: hasMoreSegments, // true = more segments auto-queued
3232
+ discovered,
3233
+ apiEndpoints: capturedApiEndpoints,
3234
+ performanceMetrics,
3235
+ pendingUrls, // feed into next segment's resumeUrls
3236
+ segmentIndex: segIdx, // which segment this was
3237
+ hasMoreSegments,
3238
+ domHashes: Array.from(seenDomHashes), // carry forward to next segment to avoid re-screenshotting
3239
+ };
3240
+ } finally {
3241
+ // Log accumulated Stagehand LLM usage (extract/act/observe calls) to cost ledger.
3242
+ if (stagehandPromptTokens > 0 || stagehandCompletionTokens > 0) {
3243
+ const totalTokens = stagehandPromptTokens + stagehandCompletionTokens;
3244
+ const shProvider = stagehandModel.includes('/') ? stagehandModel.split('/')[0] : configuredProvider || 'openai';
3245
+ const shModel = stagehandModel.includes('/') ? stagehandModel.split('/').slice(1).join('/') : stagehandModel;
3246
+ const shUsage = {
3247
+ provider: shProvider,
3248
+ model: shModel,
3249
+ promptTokens: stagehandPromptTokens,
3250
+ completionTokens: stagehandCompletionTokens,
3251
+ cacheReadTokens: 0,
3252
+ cacheWriteTokens: 0,
3253
+ totalTokens,
3254
+ };
3255
+ recordLLMCall({
3256
+ tenantId: opts.tenantId,
3257
+ projectId: opts.projectId,
3258
+ jobId: opts.jobId,
3259
+ agentType: 'crawl_stagehand',
3260
+ label: 'stagehand_browser_actions',
3261
+ usage: { ...shUsage, estimatedCostUsd: estimateCost(shUsage, `${shProvider}/${shModel}`) },
3262
+ }).catch(() => { });
3263
+ }
3264
+ await playwrightBrowser?.close().catch(() => { });
3265
+ await stagehand.close().catch(() => { });
3266
+ await chromeProc.close().catch(() => { });
3267
+ }
3268
+ }
3269
+
3270
+ // Elements whose meanings indicate UI state toggles or noise — filtered from both DOM and AI results.
3271
+ 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;
3272
+
3273
+ async function extractElements(stagehand: Stagehand, page: any, llmCfg?: { provider: string; model: string; apiKey?: string; baseUrl?: string } | null): Promise<Array<{ meaning: string; role: string; expectedData?: string; notes?: string; boundingRect?: Record<string, number>; ariaState?: Record<string, string>; parentLandmark?: string }>> {
3274
+ // llmCfg reserved for per-task model selection (future: create task-scoped Stagehand)
3275
+ // DOM-first: fast, no LLM cost, covers server-rendered and most SPAs.
3276
+ // Threshold >= 2 so a lone dark-mode button doesn't suppress AI fallback for rich pages.
3277
+ const domElements = await domFallbackElements(page);
3278
+ if (domElements.length >= 2) return domElements;
3279
+
3280
+ // Content fallback: static/marketing pages have no interactive widgets but have headings,
3281
+ // images, links, and meta tags that feed SEO/a11y/visual test generation.
3282
+ const contentElements = await contentFallbackElements(page);
3283
+ if (contentElements.length > 0) return [...domElements, ...contentElements];
3284
+
3285
+ // AI fallback: DOM found nothing or only noise — try AI (pure SPA, shadow DOM, canvas-based).
3286
+ try {
3287
+ const extracted = await Promise.race([
3288
+ stagehand.extract(
3289
+ 'Extract ONLY the meaningful form and action elements on this page — inputs, selects, textareas, and action buttons. ' +
3290
+ 'For each: a clear human-readable purpose (e.g. "Email address input", "Submit password reset button"), ' +
3291
+ 'its role (button/input/select/checkbox/textarea), and for inputs the expected data type ' +
3292
+ '(valid_email/password/phone/date/search_term/text). ' +
3293
+ 'SKIP: navigation links, header/footer links, cookie banners, theme toggles (dark mode/light mode), ' +
3294
+ 'social share buttons, version strings, decorative elements, any button inside a nav or header. ' +
3295
+ 'Focus ONLY on main content forms and primary action buttons. Return at most 20 elements.',
3296
+ ElementSchema as any
3297
+ ),
3298
+ new Promise<null>((resolve) => setTimeout(() => resolve(null), 30_000)),
3299
+ ]);
3300
+ if (!extracted) return domElements;
3301
+ // Apply noise filter to AI results — AI doesn't always follow skip instructions
3302
+ const filtered = (extracted as any).elements.filter((e: any) => !ELEMENT_NOISE.test(e.meaning ?? ''));
3303
+ return filtered.slice(0, 20);
3304
+ } catch {
3305
+ return domElements;
3306
+ }
3307
+ }
3308
+
3309
+ async function discoverLinks(stagehand: Stagehand, origin: string, page: any, llmCfg?: { provider: string; model: string; apiKey?: string; baseUrl?: string } | null, skipAi = false): Promise<string[]> {
3310
+ // Expand collapsed nav menus (hamburger, aria-haspopup, Radix closed triggers) so hidden routes
3311
+ // become part of the DOM before querying. This catches SPA nav dropdowns that only render on click.
3312
+ const navExpanded = await expandNavMenus(page);
3313
+
3314
+ // DOM-first: <a href> links — standard apps. AI supplements when DOM coverage is < 20 unique links.
3315
+ // Threshold of 20 (not 8) because nav sidebars quickly provide 8+ links while missing deep content URLs.
3316
+ const domLinks = await domFallbackLinks(page, origin);
3317
+
3318
+ // Interact with tabs, carousels, and accordions to reveal hidden content and discover more links.
3319
+ const interactiveLinks = await interactAndCollectLinks(page, origin);
3320
+
3321
+ // Dismiss any opened menus so page is clean for screenshot/element extraction after this call.
3322
+ if (navExpanded) {
3323
+ page.keyboard?.press('Escape').catch(() => { });
3324
+ await page.evaluate(() => {
3325
+ // Also click any currently-open triggers to close them (toggle pattern)
3326
+ document.querySelectorAll<HTMLElement>('[aria-expanded="true"],[data-state="open"],[data-headlessui-state="open"]').forEach(el => {
3327
+ try { el.click(); } catch { /* ignore */ }
3328
+ });
3329
+ }).catch(() => { });
3330
+ }
3331
+
3332
+ const allDomLinks = Array.from(new Set([...domLinks, ...interactiveLinks]));
3333
+
3334
+ if (allDomLinks.length >= 30 || skipAi) return allDomLinks;
3335
+
3336
+ // AI complement: DOM found fewer than 30 unique links — SPA likely has JS-router pages not in <a href>.
3337
+ // Always merge DOM + AI so we never lose already-discovered links.
3338
+ // 12s timeout: allows 1 OpenAI retry (4.8s delay) but bails before the 2nd retry wastes more time.
3339
+ try {
3340
+ const extracted = await Promise.race([
3341
+ stagehand.extract(
3342
+ `Find all in-app navigation links for this application (origin: ${origin}). ` +
3343
+ 'Include: main nav, sidebar, content links, feature pages, settings pages. ' +
3344
+ 'Exclude: logout, sign out, delete, external sites, anchor-only links (#), mailto:, javascript:.',
3345
+ LinksSchema as any
3346
+ ),
3347
+ new Promise<null>((resolve) => setTimeout(() => resolve(null), 12_000)),
3348
+ ]);
3349
+ if (!extracted) return allDomLinks;
3350
+ const { links } = extracted as any;
3351
+ const currentUrl: string = page.url?.() ?? '';
3352
+ const aiLinks = links
3353
+ .map((href: string) => {
3354
+ try { const u = new URL(href, currentUrl); if (!/^#!?\//.test(u.hash)) u.hash = ''; return u.toString(); }
3355
+ catch { return null; }
3356
+ })
3357
+ .filter((u: string | null): u is string => {
3358
+ if (!u) return false;
3359
+ return isUsefulNavigationUrl(u, origin);
3360
+ });
3361
+ // Always merge DOM + AI — AI complements, never replaces
3362
+ return Array.from(new Set([...allDomLinks, ...aiLinks])).filter((href) => isUsefulNavigationUrl(href, origin));
3363
+ } catch (err: any) {
3364
+ // Detect OpenAI rate-limit errors — signal caller via thrown special error so circuit breaker can trip
3365
+ const is429 = err?.status === 429 || String(err?.message ?? '').includes('429') || String(err?.message ?? '').includes('rate limit');
3366
+ if (is429) throw Object.assign(new Error('RATE_LIMITED'), { isRateLimit: true, domLinks: allDomLinks });
3367
+ return allDomLinks;
3368
+ }
3369
+ }
3370
+
3371
+ async function domFallbackElements(page: any): Promise<Array<{ meaning: string; role: string; expectedData?: string; notes?: string; boundingRect?: Record<string, number>; ariaState?: Record<string, string>; parentLandmark?: string }>> {
3372
+ return page.evaluate(() => {
3373
+ const out: { meaning: string; role: string; expectedData?: string; notes?: string; boundingRect?: Record<string, number>; ariaState?: Record<string, string>; parentLandmark?: string }[] = [];
3374
+ const seen = new Set<string>();
3375
+ // UI chrome noise: cookie banners, theme toggles, version strings, social/share buttons
3376
+ 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;
3377
+
3378
+ const querySelectorAllDeep = (root: any, selector: string): Element[] => {
3379
+ const results: Element[] = Array.from(root.querySelectorAll(selector));
3380
+ root.querySelectorAll('*').forEach((el: any) => {
3381
+ if (el.shadowRoot) {
3382
+ results.push(...querySelectorAllDeep(el.shadowRoot, selector));
3383
+ }
3384
+ });
3385
+ return results;
3386
+ };
3387
+
3388
+ // Expanded selector with more ARIA roles + rich text editors
3389
+ const selector = 'input:not([type=hidden]),select,textarea,button,[role="button"],[role="combobox"],[role="slider"],[role="spinbutton"],[role="switch"],[role="tab"],[role="dialog"],[role="alertdialog"],[role="menu"],[role="menuitem"],[role="listbox"],[role="option"],[role="treeitem"],[role="searchbox"],a[role="button"],[contenteditable="true"]';
3390
+
3391
+ querySelectorAllDeep(document, selector).forEach((el: any) => {
3392
+ const style = window.getComputedStyle(el);
3393
+ // Enhanced actionability checks
3394
+ if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0' || style.pointerEvents === 'none') return;
3395
+ if (el.hasAttribute('inert') || el.getAttribute('aria-disabled') === 'true') return;
3396
+
3397
+ const rect = el.getBoundingClientRect();
3398
+ if (rect.width === 0 && rect.height === 0) return;
3399
+
3400
+ const tag = el.tagName.toLowerCase();
3401
+ const ariaRole = el.getAttribute('role');
3402
+ const isActionEl = tag === 'button' || tag === 'a' || ariaRole === 'button';
3403
+
3404
+ // Skip plain anchor links inside <header> or <footer> — navigation chrome with no test value.
3405
+ // Do NOT skip <nav> elements: SPA dashboard sidebars live in <nav> and are primary test targets.
3406
+ // Buttons inside nav (e.g. collapse, settings) are kept; only <a> links are skipped.
3407
+ if (tag === 'a' || (ariaRole === 'button' && tag !== 'button')) {
3408
+ let p = el.parentElement;
3409
+ while (p) {
3410
+ const ptag = p.tagName?.toLowerCase();
3411
+ if (['header', 'footer'].includes(ptag)) return;
3412
+ p = p.parentElement;
3413
+ }
3414
+ }
3415
+
3416
+ // Label resolution: explicit <label for>, implicit wrapping <label>, aria-label, placeholder
3417
+ let labelText = '';
3418
+ if (el.id) {
3419
+ const linkedLabel = document.querySelector(`label[for="${el.id}"]`);
3420
+ if (linkedLabel) labelText = linkedLabel.textContent?.trim() || '';
3421
+ }
3422
+ // Implicit label: input is a child of <label> (no for= attribute needed)
3423
+ if (!labelText) {
3424
+ const wrappingLabel = el.closest('label');
3425
+ if (wrappingLabel) {
3426
+ const clone = wrappingLabel.cloneNode(true) as Element;
3427
+ clone.querySelectorAll('input,select,textarea,button').forEach((c: any) => c.remove());
3428
+ labelText = clone.textContent?.replace(/\s+/g, ' ').trim() || '';
3429
+ }
3430
+ }
3431
+
3432
+ const label = (
3433
+ el.getAttribute('aria-label') ||
3434
+ el.getAttribute('placeholder') ||
3435
+ labelText ||
3436
+ el.innerText?.replace(/\s+/g, ' ').trim() ||
3437
+ el.getAttribute('name') ||
3438
+ ''
3439
+ ).slice(0, 80);
3440
+
3441
+ if (!label || noise.test(label) || label.length > 70) return;
3442
+
3443
+ const role = tag === 'input' ? (el.type || 'text') : el.hasAttribute('contenteditable') ? 'rich-text' : (ariaRole || tag);
3444
+ const key = `${role}:${label.toLowerCase()}`;
3445
+ if (seen.has(key)) return;
3446
+ seen.add(key);
3447
+
3448
+ let expectedData: string | undefined;
3449
+ if (tag === 'input') {
3450
+ if (el.type === 'email' || /email/i.test(label)) expectedData = 'valid_email';
3451
+ else if (el.type === 'password' || /password/i.test(label)) expectedData = 'password';
3452
+ else if (el.type === 'tel' || /phone|mobile/i.test(label)) expectedData = 'phone';
3453
+ else if (el.type === 'date' || /\bdate\b/i.test(label)) expectedData = 'date';
3454
+ else if (/\b(code|otp|token)\b/i.test(label)) expectedData = 'one_time_code';
3455
+ else if (/\b(search|query)\b/i.test(label)) expectedData = 'search_term';
3456
+ else if (el.type === 'number' || /\b(amount|quantity|count|age|year)\b/i.test(label)) expectedData = 'number';
3457
+ else if (el.type === 'url' || /\b(url|website|link)\b/i.test(label)) expectedData = 'url';
3458
+ else if (el.type === 'file') expectedData = 'file';
3459
+ }
3460
+
3461
+ // Multi-Attribute Identity
3462
+ const attrs: Record<string, string> = {};
3463
+ if (el.id) attrs.id = el.id;
3464
+ if (el.name) attrs.name = el.name;
3465
+ if (el.getAttribute('data-testid')) attrs['data-testid'] = el.getAttribute('data-testid');
3466
+
3467
+ // HTML5 validation attributes — ground test boundary values in real constraints
3468
+ if (tag === 'input' || tag === 'textarea') {
3469
+ if (el.required) attrs.required = 'true';
3470
+ if (el.pattern) attrs.pattern = el.pattern;
3471
+ if (el.minLength > 0) attrs.minLength = String(el.minLength);
3472
+ if (el.maxLength > 0 && el.maxLength < 524288) attrs.maxLength = String(el.maxLength);
3473
+ if (el.min !== '') attrs.min = String(el.min);
3474
+ if (el.max !== '') attrs.max = String(el.max);
3475
+ if (el.step && el.step !== 'any') attrs.step = String(el.step);
3476
+ if (el.accept) attrs.accept = el.accept;
3477
+ if (el.multiple) attrs.multiple = 'true';
3478
+ if (el.readOnly) attrs.readOnly = 'true';
3479
+ if (el.disabled) attrs.disabled = 'true';
3480
+ if (el.autocomplete && el.autocomplete !== 'on') attrs.autocomplete = el.autocomplete;
3481
+ }
3482
+ // CSS class hint — first component-library class for COMPONENT file detection
3483
+ const cssClass = (el.className || '')
3484
+ .split(' ')
3485
+ .map((c: string) => c.trim())
3486
+ .find((c: string) => c.length > 2 && !/^(true|false|active|disabled|hidden|visible)$/i.test(c));
3487
+ if (cssClass) attrs.cssClass = cssClass;
3488
+
3489
+ // GAP 4: Select option enumeration — test generator knows valid values
3490
+ if (tag === 'select' && el.options?.length > 0 && el.options.length <= 60) {
3491
+ const opts = Array.from(el.options).map((o: any) => {
3492
+ const entry: Record<string, any> = { v: o.value, l: o.text.trim() };
3493
+ if (o.selected) entry.s = true;
3494
+ return entry;
3495
+ }).filter((o: any) => o.l);
3496
+ if (opts.length > 0) attrs.selectOptions = JSON.stringify(opts);
3497
+ }
3498
+
3499
+ // GAP 6: aria-describedby help text — captures real constraint text (e.g. "min 8 chars")
3500
+ const describedById = el.getAttribute('aria-describedby');
3501
+ if (describedById) {
3502
+ const helpEl = document.getElementById(describedById.split(' ')[0]);
3503
+ const helpText = helpEl?.textContent?.trim().slice(0, 200);
3504
+ if (helpText) attrs.helpText = helpText;
3505
+ }
3506
+
3507
+ // GAP 1: Form grouping — inputs/buttons tagged with their containing form
3508
+ const form = el.closest('form');
3509
+ if (form) {
3510
+ if ((form as any).id) attrs.formId = (form as any).id;
3511
+ const formAction = form.getAttribute('action');
3512
+ if (formAction && !formAction.startsWith('javascript:')) attrs.formAction = formAction.slice(0, 120);
3513
+ const formMethod = (form as any).method;
3514
+ if (formMethod) attrs.formMethod = formMethod.toUpperCase();
3515
+ }
3516
+ const fieldset = el.closest('fieldset');
3517
+ if (fieldset) {
3518
+ const legendText = fieldset.querySelector('legend')?.textContent?.trim().slice(0, 60);
3519
+ if (legendText) attrs.fieldsetGroup = legendText;
3520
+ }
3521
+
3522
+ // GAP 7: Button classification — type + destructive intent
3523
+ if (tag === 'button' || ariaRole === 'button') {
3524
+ if (el.type && el.type !== 'button') attrs.buttonType = el.type; // submit | reset
3525
+ if (/\b(delete|remove|destroy|revoke|clear|reset|discard|cancel)\b/i.test(label)) attrs.isDestructive = 'true';
3526
+ }
3527
+
3528
+ // ARIA state snapshot — promoted to first-class field (Phase 1)
3529
+ const stateMap: Record<string, string> = {};
3530
+ const ariaExpanded = el.getAttribute('aria-expanded');
3531
+ const ariaChecked = el.getAttribute('aria-checked');
3532
+ const ariaPressed = el.getAttribute('aria-pressed');
3533
+ const ariaSelected = el.getAttribute('aria-selected');
3534
+ const ariaInvalid = el.getAttribute('aria-invalid');
3535
+ const ariaCurrent = el.getAttribute('aria-current');
3536
+ if (ariaExpanded !== null) stateMap.expanded = ariaExpanded;
3537
+ if (ariaChecked !== null) stateMap.checked = ariaChecked;
3538
+ if (ariaPressed !== null) stateMap.pressed = ariaPressed;
3539
+ if (ariaSelected !== null) stateMap.selected = ariaSelected;
3540
+ if (ariaInvalid && ariaInvalid !== 'false') stateMap.invalid = ariaInvalid;
3541
+ if (ariaCurrent) stateMap.current = ariaCurrent;
3542
+ if (el.getAttribute('aria-required') === 'true' && !attrs.required) stateMap.required = 'true';
3543
+
3544
+ // Parent landmark — promoted to first-class field (Phase 1)
3545
+ const getRegion = (node: any): string | undefined => {
3546
+ let p = node.parentElement;
3547
+ while (p && p !== document.body) {
3548
+ const ptag = p.tagName?.toLowerCase();
3549
+ const prole = p.getAttribute?.('role');
3550
+ if (prole === 'dialog' || prole === 'alertdialog') return 'modal';
3551
+ if (prole === 'main' || ptag === 'main') return 'main';
3552
+ if (prole === 'complementary' || ptag === 'aside') return 'aside';
3553
+ if (prole === 'navigation' || ptag === 'nav') return 'nav';
3554
+ if (prole === 'banner' || ptag === 'header') return 'header';
3555
+ if (prole === 'contentinfo' || ptag === 'footer') return 'footer';
3556
+ p = p.parentElement;
3557
+ }
3558
+ return undefined;
3559
+ };
3560
+ const parentLandmark = getRegion(el);
3561
+
3562
+ // GAP 8: Rich text editor detection
3563
+ if (el.hasAttribute('contenteditable')) {
3564
+ const cls = ((el.className || '') + ' ' + (el.getAttribute('data-type') || '')).toLowerCase();
3565
+ const editorType = cls.includes('tiptap') || cls.includes('prosemirror') ? 'tiptap' :
3566
+ cls.includes('cm-') || cls.includes('codemirror') ? 'codemirror' :
3567
+ (window as any).Quill ? 'quill' :
3568
+ typeof (window as any).monaco !== 'undefined' ? 'monaco' : 'contenteditable';
3569
+ attrs.editorType = editorType;
3570
+ }
3571
+
3572
+ const notes = Object.keys(attrs).length > 0 ? JSON.stringify(attrs) : undefined;
3573
+ const boundingRect = {
3574
+ x: Math.round(rect.x), y: Math.round(rect.y),
3575
+ width: Math.round(rect.width), height: Math.round(rect.height),
3576
+ top: Math.round(rect.top), left: Math.round(rect.left),
3577
+ right: Math.round(rect.right), bottom: Math.round(rect.bottom),
3578
+ };
3579
+ out.push({
3580
+ meaning: label, role, expectedData, notes,
3581
+ boundingRect,
3582
+ ariaState: Object.keys(stateMap).length > 0 ? stateMap : undefined,
3583
+ parentLandmark: parentLandmark ?? undefined,
3584
+ });
3585
+ });
3586
+
3587
+ // Form fields first, then buttons
3588
+ const priority = (e: typeof out[0]) =>
3589
+ ['email', 'password', 'text', 'tel', 'date', 'number', 'select', 'textarea', 'url', 'file'].includes(e.role) ? 0 : 1;
3590
+ out.sort((a, b) => priority(a) - priority(b));
3591
+ return out; // Cap removed
3592
+ }).catch(() => []);
3593
+ }
3594
+
3595
+ /** For pages with no interactive elements (marketing/static pages), capture content anatomy:
3596
+ * headings, images-with-alt, CTA links outside nav/header/footer, and meta tags.
3597
+ * These give the test generator something to work with for SEO, a11y, and visual tests. */
3598
+ async function contentFallbackElements(page: any): Promise<Array<{ meaning: string; role: string; expectedData?: string; notes?: string }>> {
3599
+ return page.evaluate(() => {
3600
+ const out: { meaning: string; role: string; notes?: string }[] = [];
3601
+ const seen = new Set<string>();
3602
+ const addUniq = (item: { meaning: string; role: string; notes?: string }) => {
3603
+ const key = `${item.role}:${item.meaning.toLowerCase().slice(0, 60)}`;
3604
+ if (!seen.has(key) && item.meaning.trim().length > 0) { seen.add(key); out.push(item); }
3605
+ };
3606
+
3607
+ // 1. Page-level meta (title, description, og:)
3608
+ const title = document.title?.trim();
3609
+ if (title) addUniq({ meaning: title, role: 'page-title', notes: JSON.stringify({ tag: 'title' }) });
3610
+ const desc = document.querySelector('meta[name="description"]')?.getAttribute('content')?.trim();
3611
+ if (desc) addUniq({ meaning: desc, role: 'meta-description', notes: JSON.stringify({ tag: 'meta[name=description]' }) });
3612
+ const ogTitle = document.querySelector('meta[property="og:title"]')?.getAttribute('content')?.trim();
3613
+ if (ogTitle) addUniq({ meaning: ogTitle, role: 'og-title', notes: JSON.stringify({ tag: 'meta[property=og:title]' }) });
3614
+ const canonical = document.querySelector('link[rel="canonical"]')?.getAttribute('href')?.trim();
3615
+ if (canonical) addUniq({ meaning: canonical, role: 'canonical-url', notes: JSON.stringify({ tag: 'link[rel=canonical]' }) });
3616
+
3617
+ // 2. Headings — page structure
3618
+ document.querySelectorAll('h1, h2, h3').forEach((h: any) => {
3619
+ const text = h.textContent?.replace(/\s+/g, ' ').trim().slice(0, 80);
3620
+ if (text) addUniq({ meaning: text, role: h.tagName.toLowerCase(), notes: JSON.stringify({ level: h.tagName }) });
3621
+ });
3622
+
3623
+ // 3. Images with alt text — accessibility targets
3624
+ document.querySelectorAll('img[alt]').forEach((img: any) => {
3625
+ const alt = img.getAttribute('alt')?.trim().slice(0, 80);
3626
+ const src = img.getAttribute('src')?.slice(0, 120);
3627
+ if (alt) addUniq({ meaning: alt, role: 'image', notes: JSON.stringify({ src }) });
3628
+ });
3629
+
3630
+ // 4. CTA / content links — outside nav, header, footer
3631
+ const navTags = new Set(['nav', 'header', 'footer']);
3632
+ document.querySelectorAll('a[href]').forEach((a: any) => {
3633
+ let p = a.parentElement;
3634
+ while (p) { if (navTags.has(p.tagName?.toLowerCase())) return; p = p.parentElement; }
3635
+ const label = (a.getAttribute('aria-label') || a.textContent?.replace(/\s+/g, ' ').trim() || '').slice(0, 80);
3636
+ const href = a.href;
3637
+ if (label && href && !/^(#|javascript:|mailto:)/i.test(href)) {
3638
+ const isExternal = !href.includes(location.hostname);
3639
+ addUniq({ meaning: label, role: isExternal ? 'external-link' : 'internal-link', notes: JSON.stringify({ href: href.slice(0, 120) }) });
3640
+ }
3641
+ });
3642
+
3643
+ // 5. Animated / interactive content markers (CSS animation, video, canvas)
3644
+ const hasAnimation = !!document.querySelector('[class*="animate"], [class*="motion"], [class*="transition"], [class*="scroll-"], [data-aos], [data-scroll]');
3645
+ if (hasAnimation) addUniq({ meaning: 'CSS animations / scroll effects detected on page', role: 'animation-layer', notes: JSON.stringify({ detected: true }) });
3646
+ if (document.querySelector('video')) addUniq({ meaning: 'Video element present', role: 'video', notes: JSON.stringify({ count: document.querySelectorAll('video').length }) });
3647
+ if (document.querySelector('canvas')) addUniq({ meaning: 'Canvas element present', role: 'canvas' });
3648
+
3649
+ return out.slice(0, 30);
3650
+ }).catch(() => []);
3651
+ }
3652
+
3653
+ /**
3654
+ * Click through tabs, advance carousels, open accordions — collect links that only appear
3655
+ * inside those interactive panels. Does not navigate away from the current page.
3656
+ */
3657
+ async function interactAndCollectLinks(page: any, origin: string): Promise<string[]> {
3658
+ const collected = new Set<string>();
3659
+ const sleep = (ms: number) => new Promise<void>(r => setTimeout(r, ms));
3660
+
3661
+ const collectCurrentLinks = async () => {
3662
+ const links: string[] = await page.evaluate((origin: string) => {
3663
+ const out: string[] = [];
3664
+ const logout = /logout|log out|sign out|delete/i;
3665
+ document.querySelectorAll<HTMLAnchorElement>('a[href]').forEach(a => {
3666
+ if (logout.test(a.textContent ?? '')) return;
3667
+ try {
3668
+ const u = new URL(a.href);
3669
+ if (u.origin === origin) { if (u.hash && !/^#!?\//.test(u.hash)) u.hash = ''; out.push(u.toString()); }
3670
+ } catch { /* ignore */ }
3671
+ });
3672
+ return out;
3673
+ }, origin).catch(() => []);
3674
+ links.forEach(l => collected.add(l));
3675
+ };
3676
+
3677
+ try {
3678
+ // ── Tabs: click each tab individually, collect links from revealed panel ──
3679
+ const tabCount: number = await page.evaluate(() =>
3680
+ document.querySelectorAll('[role="tab"]').length
3681
+ ).catch(() => 0);
3682
+
3683
+ for (let i = 0; i < Math.min(tabCount, 15); i++) {
3684
+ try {
3685
+ const clicked = await page.evaluate((idx: number) => {
3686
+ const tabs = document.querySelectorAll<HTMLElement>('[role="tab"]');
3687
+ const tab = tabs[idx];
3688
+ if (!tab) return false;
3689
+ const s = window.getComputedStyle(tab);
3690
+ const r = tab.getBoundingClientRect();
3691
+ if (s.display === 'none' || r.width < 4 || r.height < 4) return false;
3692
+ tab.click();
3693
+ return true;
3694
+ }, i).catch(() => false);
3695
+ if (clicked) {
3696
+ await sleep(350);
3697
+ await collectCurrentLinks();
3698
+ }
3699
+ } catch { /* skip */ }
3700
+ }
3701
+
3702
+ // ── Carousels: advance next button up to 8 times, collect links each slide ──
3703
+ const carouselNextSel = [
3704
+ 'button[aria-label*="next" i]', 'button[aria-label*="forward" i]',
3705
+ 'button[aria-label*="right" i]', '[data-direction="next"]',
3706
+ '[class*="carousel" i] button[class*="next" i]',
3707
+ '[class*="slider" i] button[class*="next" i]',
3708
+ ].join(', ');
3709
+
3710
+ for (let i = 0; i < 8; i++) {
3711
+ const advanced: boolean = await page.evaluate((sel: string) => {
3712
+ const btn = document.querySelector<HTMLElement>(sel);
3713
+ if (!btn) return false;
3714
+ const s = window.getComputedStyle(btn);
3715
+ const r = btn.getBoundingClientRect();
3716
+ if (s.display === 'none' || r.width < 4 || r.height < 4) return false;
3717
+ btn.click();
3718
+ return true;
3719
+ }, carouselNextSel).catch(() => false);
3720
+ if (!advanced) break;
3721
+ await sleep(300);
3722
+ await collectCurrentLinks();
3723
+ }
3724
+
3725
+ // ── Accordions / disclosure panels: open collapsed sections ──────────────
3726
+ const accordionCount: number = await page.evaluate(() =>
3727
+ document.querySelectorAll('details:not([open]) > summary, [data-state="closed"] > button, [aria-expanded="false"][class*="accordion" i]').length
3728
+ ).catch(() => 0);
3729
+
3730
+ for (let i = 0; i < Math.min(accordionCount, 10); i++) {
3731
+ try {
3732
+ await page.evaluate((idx: number) => {
3733
+ const items = document.querySelectorAll<HTMLElement>(
3734
+ 'details:not([open]) > summary, [data-state="closed"] > button, [aria-expanded="false"][class*="accordion" i]'
3735
+ );
3736
+ const el = items[idx];
3737
+ if (el) { try { el.click(); } catch { /* ignore */ } }
3738
+ }, i).catch(() => { });
3739
+ await sleep(250);
3740
+ await collectCurrentLinks();
3741
+ } catch { /* skip */ }
3742
+ }
3743
+ } catch { /* best-effort */ }
3744
+
3745
+ return Array.from(collected);
3746
+ }
3747
+
3748
+ /** Click collapsed nav triggers (hamburger menus, aria-haspopup buttons, Radix/Headless UI toggles)
3749
+ * to reveal routes that only exist in the DOM after user interaction.
3750
+ * Returns true if anything was clicked so caller can dismiss afterward. */
3751
+ async function expandNavMenus(page: any): Promise<boolean> {
3752
+ try {
3753
+ const clicked = await page.evaluate(() => {
3754
+ const isVisible = (el: HTMLElement) => {
3755
+ const s = window.getComputedStyle(el);
3756
+ if (s.display === 'none' || s.visibility === 'hidden' || parseFloat(s.opacity) < 0.1) return false;
3757
+ const r = el.getBoundingClientRect();
3758
+ return r.width > 4 && r.height > 4;
3759
+ };
3760
+ const inBadContext = (el: HTMLElement) => {
3761
+ let p = el.parentElement;
3762
+ while (p) {
3763
+ const tag = p.tagName?.toLowerCase();
3764
+ const role = p.getAttribute('role') ?? '';
3765
+ if (['form', 'table', 'select'].includes(tag ?? '') ||
3766
+ ['dialog', 'alertdialog', 'combobox', 'listbox', 'grid', 'treegrid'].includes(role)) return true;
3767
+ p = p.parentElement;
3768
+ }
3769
+ return false;
3770
+ };
3771
+ const targets = new Set<HTMLElement>();
3772
+ // aria-haspopup: canonical menu-trigger pattern
3773
+ document.querySelectorAll<HTMLElement>('[aria-haspopup]:not([aria-expanded="true"])').forEach(el => {
3774
+ if (isVisible(el) && !inBadContext(el)) targets.add(el);
3775
+ });
3776
+ // aria-expanded=false: collapsed nav sections
3777
+ document.querySelectorAll<HTMLElement>('[aria-expanded="false"]').forEach(el => {
3778
+ if ((el.tagName === 'BUTTON' || el.getAttribute('role') === 'button') && isVisible(el) && !inBadContext(el)) targets.add(el);
3779
+ });
3780
+ // Radix UI / Headless UI closed triggers
3781
+ document.querySelectorAll<HTMLElement>('[data-state="closed"],[data-headlessui-state="closed"]').forEach(el => {
3782
+ if ((el.tagName === 'BUTTON' || el.getAttribute('role') === 'button') && isVisible(el) && !inBadContext(el)) targets.add(el);
3783
+ });
3784
+ let count = 0;
3785
+ targets.forEach(el => { try { (el as HTMLElement).click(); count++; } catch { /* ignore */ } });
3786
+ return count;
3787
+ });
3788
+ if (clicked > 0) await new Promise(r => setTimeout(r, 500));
3789
+
3790
+ // ── Hover sweep: reveal CSS :hover dropdown menus ──────────────────────
3791
+ // Pure CSS hover menus need real mouse moves — JS click alone won't trigger :hover
3792
+ try {
3793
+ const navHandles = await page.$$(
3794
+ 'nav > ul > li, nav > li, header > ul > li, header > nav > ul > li, ' +
3795
+ '[role="navigation"] > ul > li, [role="menubar"] > [role="menuitem"], ' +
3796
+ '[class*="nav"] > ul > li, [class*="menu"] > li'
3797
+ ).catch(() => [] as any[]);
3798
+ for (const h of (navHandles as any[]).slice(0, 20)) {
3799
+ try { await h.hover({ force: true, timeout: 600 }); await page.waitForTimeout(280); } catch { /* skip invisible */ }
3800
+ }
3801
+ if ((navHandles as any[]).length > 0) {
3802
+ await page.mouse.move(2, 2); // dismiss hover state
3803
+ await page.waitForTimeout(150);
3804
+ }
3805
+ } catch { /* non-fatal */ }
3806
+
3807
+ return (clicked as number) > 0;
3808
+ } catch { return false; }
3809
+ }
3810
+
3811
+ async function domFallbackLinks(page: any, origin: string): Promise<string[]> {
3812
+ return page.evaluate((origin: string) => {
3813
+ const logout = /logout|log out|sign out|delete/i;
3814
+ // Hash-routed SPAs (React Router hash history, Vue Router hash mode, old
3815
+ // AngularJS) encode the actual route as the fragment — "#/dashboard",
3816
+ // "#!/settings" — as distinct from a same-page scroll anchor like "#top"
3817
+ // or "#section-2". Treating every hash as a scroll anchor (stripping it
3818
+ // entirely) collapses an entire hash-routed app down to its single base
3819
+ // URL, discovering only ~1 screen no matter how many routes it has.
3820
+ const isRouteHash = (hash: string) => /^#!?\//.test(hash);
3821
+ const seen = new Set<string>();
3822
+ const add = (rawHref: string, textContent: string) => {
3823
+ if (logout.test(textContent)) return;
3824
+ if (!rawHref) return;
3825
+ if (rawHref.startsWith('#') && !isRouteHash(rawHref)) return;
3826
+ if (rawHref.startsWith('mailto:') || rawHref.startsWith('javascript:')) return;
3827
+ try {
3828
+ const u = new URL(rawHref, location.href);
3829
+ if (!isRouteHash(u.hash)) u.hash = '';
3830
+ if (new URL(u).origin === origin) seen.add(u.toString());
3831
+ } catch { /* ignore */ }
3832
+ };
3833
+
3834
+ // Iterative BFS shadow DOM traversal — avoids stack overflow on deeply nested shadow roots
3835
+ const allRoots: Array<Document | ShadowRoot> = [document];
3836
+ const rootSeen = new WeakSet<Document | ShadowRoot>();
3837
+ rootSeen.add(document);
3838
+ while (allRoots.length > 0) {
3839
+ const root = allRoots.shift();
3840
+ if (!root) continue;
3841
+ try {
3842
+ root.querySelectorAll('a[href]').forEach((a) => add((a as HTMLAnchorElement).href || a.getAttribute('href') || '', a.textContent || ''));
3843
+ root.querySelectorAll('[href]:not(a):not(link):not(base):not(script)').forEach((el) => {
3844
+ add(el.getAttribute('href') || '', el.textContent || '');
3845
+ });
3846
+ root.querySelectorAll('[data-href]').forEach((el) => {
3847
+ add(el.getAttribute('data-href') || '', el.textContent || '');
3848
+ });
3849
+ root.querySelectorAll('*').forEach((el) => {
3850
+ try {
3851
+ if (el.shadowRoot && !rootSeen.has(el.shadowRoot)) {
3852
+ rootSeen.add(el.shadowRoot);
3853
+ allRoots.push(el.shadowRoot);
3854
+ }
3855
+ } catch (_) { /* closed/cross-origin shadow root — skip */ }
3856
+ });
3857
+ } catch (_) { /* element threw — skip this root */ }
3858
+ }
3859
+ return Array.from(seen);
3860
+ }, origin).catch(() => []);
3861
+ }
3862
+
3863
+ /** Extract label→href pairs for all in-app anchor links — used to seed KAG element transitions. */
3864
+ async function extractLinkMappings(page: any, origin: string): Promise<Array<{ label: string; href: string }>> {
3865
+ return page.evaluate((origin: string) => {
3866
+ const logout = /logout|log out|sign out|delete/i;
3867
+ return Array.from(document.querySelectorAll('a[href]'))
3868
+ .filter((a: any) => !logout.test(a.textContent ?? ''))
3869
+ .map((a: any) => {
3870
+ const label = (
3871
+ a.getAttribute('aria-label') ||
3872
+ a.textContent?.replace(/\s+/g, ' ').trim() ||
3873
+ a.getAttribute('title') ||
3874
+ ''
3875
+ ).slice(0, 80);
3876
+ const href = (() => { try { const u = new URL(a.href); if (!/^#!?\//.test(u.hash)) u.hash = ''; return u.toString(); } catch { return ''; } })();
3877
+ return { label, href };
3878
+ })
3879
+ .filter(({ label, href }: { label: string; href: string }) =>
3880
+ label.length > 2 && href.length > 0 &&
3881
+ (() => { try { return new URL(href).origin === origin; } catch { return false; } })()
3882
+ );
3883
+ }, origin).catch(() => []);
3884
+ }
3885
+
3886
+ /**
3887
+ * Computes a structural hash of the DOM to detect repeating screens/components.
3888
+ * Strips out text and focuses only on the sequence of semantic/form tags.
3889
+ */
3890
+ async function computeDomHash(page: any): Promise<string> {
3891
+ return page.evaluate(() => {
3892
+ let hashStr = '';
3893
+ const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_ELEMENT);
3894
+ let node;
3895
+ while ((node = walker.nextNode())) {
3896
+ const el = node as HTMLElement;
3897
+ // Only include structural/semantic tags that matter for UI layouts
3898
+ if (/^(MAIN|SECTION|NAV|FORM|INPUT|SELECT|TEXTAREA|BUTTON|TABLE|TR|TD|TH|UL|LI|A|H1|H2|H3)$/.test(el.tagName)) {
3899
+ // Count structural nesting depth — distinguishes modal dialogs (deeply nested)
3900
+ // from top-level nav/forms (shallow), preventing false duplicate matches.
3901
+ let depth = 0;
3902
+ let parent = el.parentElement;
3903
+ while (parent) {
3904
+ if (/^(MAIN|SECTION|NAV|FORM|INPUT|SELECT|TEXTAREA|BUTTON|TABLE|TR|TD|TH|UL|LI|A|H1|H2|H3)$/.test(parent.tagName)) depth++;
3905
+ parent = parent.parentElement;
3906
+ }
3907
+ hashStr += el.tagName + depth + '|';
3908
+ }
3909
+ }
3910
+ // Very simple hashing function (djb2 style)
3911
+ let hash = 5381;
3912
+ for (let i = 0; i < hashStr.length; i++) {
3913
+ hash = (hash * 33) ^ hashStr.charCodeAt(i);
3914
+ }
3915
+ return (hash >>> 0).toString(16);
3916
+ }).catch(() => '');
3917
+ }
3918
+
3919
+ async function waitForURLChange(page: any, fromUrl: string, timeout = 10_000): Promise<void> {
3920
+ const deadline = Date.now() + timeout;
3921
+ while (Date.now() < deadline) {
3922
+ const current = page.url();
3923
+ try {
3924
+ const from = new URL(fromUrl);
3925
+ const now = new URL(current);
3926
+ if (now.pathname !== from.pathname || now.origin !== from.origin) return;
3927
+ } catch {
3928
+ if (current !== fromUrl) return;
3929
+ }
3930
+ await new Promise(r => setTimeout(r, 200));
3931
+ }
3932
+ // Timed out — proceed with current URL (may still be login page)
3933
+ }
3934
+
3935
+ /**
3936
+ * Dismiss common overlay patterns (cookie banners, GDPR consent, chat widgets, newsletter modals)
3937
+ * before element extraction — prevents banner elements from polluting the element model.
3938
+ */
3939
+ async function dismissOverlays(page: any): Promise<void> {
3940
+ const selectors = [
3941
+ // OneTrust / CookieBot / TrustArc (most common GDPR banners)
3942
+ '#onetrust-accept-btn-handler',
3943
+ '#accept-cookie-consent',
3944
+ '.cc-btn.cc-allow',
3945
+ '[data-cookiebanner] button',
3946
+ '[id*="cookie-accept"]',
3947
+ '[class*="cookie-accept"]',
3948
+ '[id*="cookieAccept"]',
3949
+ '[class*="cookieAccept"]',
3950
+ 'button[aria-label*="Accept cookies" i]',
3951
+ 'button[aria-label*="Accept all" i]',
3952
+ 'button[title*="Accept" i][class*="cookie" i]',
3953
+ // Intercom / Drift / HubSpot chat
3954
+ '[class*="intercom-close"]',
3955
+ '#hubspot-messages-iframe-container [aria-label*="close" i]',
3956
+ // Generic overlay close buttons
3957
+ '[role="dialog"] button[aria-label="Close" i]',
3958
+ '[role="dialog"] button[aria-label="Dismiss" i]',
3959
+ // Newsletter popups
3960
+ 'button[class*="modal-close"]',
3961
+ 'button[class*="popup-close"]',
3962
+ '[class*="newsletter"] button[class*="close"]',
3963
+ ];
3964
+ for (const sel of selectors) {
3965
+ try {
3966
+ const el = page.locator(sel).first();
3967
+ if (await el.isVisible({ timeout: 300 }).catch(() => false)) {
3968
+ await el.click({ timeout: 500, force: true }).catch(() => { });
3969
+ }
3970
+ } catch { /* overlay not present — skip */ }
3971
+ }
3972
+ // Brief settle after dismissals
3973
+ await page.waitForTimeout(300).catch(() => { });
3974
+ }
3975
+
3976
+ /**
3977
+ * Scroll the full page height to trigger IntersectionObserver-based lazy loads
3978
+ * (images, components, infinite-scroll content). Scrolls back to top before capture.
3979
+ */
3980
+ async function scrollToTriggerLazyLoad(page: any): Promise<void> {
3981
+ try {
3982
+ await page.evaluate(async () => {
3983
+ const step = Math.max(window.innerHeight, 400);
3984
+ const total = Math.min(document.body.scrollHeight, 12_000); // cap at 12k px to avoid infinite pages
3985
+ for (let y = step; y < total; y += step) {
3986
+ window.scrollTo(0, y);
3987
+ await new Promise<void>(r => setTimeout(r, 120));
3988
+ }
3989
+ window.scrollTo(0, 0);
3990
+ });
3991
+ // Let lazy-triggered content render before extraction
3992
+ await page.waitForTimeout(400).catch(() => { });
3993
+ // Re-check DOM stability after scroll-triggered mutations
3994
+ await waitForStableDOM(page, 3_000, 300);
3995
+ } catch { /* scroll failed — non-fatal, proceed */ }
3996
+ }
3997
+
3998
+ async function waitForStableDOM(page: any, maxWait = 8_000, stabilityMs = 400): Promise<void> {
3999
+ const deadline = Date.now() + maxWait;
4000
+ let lastCount = -1;
4001
+ let stableAt = 0;
4002
+ while (Date.now() < deadline) {
4003
+ const count: number = await page.evaluate(() => document.querySelectorAll('*').length).catch(() => 0);
4004
+ if (count !== lastCount) {
4005
+ lastCount = count;
4006
+ stableAt = Date.now();
4007
+ } else if (Date.now() - stableAt >= stabilityMs) {
4008
+ return;
4009
+ }
4010
+ await new Promise(r => setTimeout(r, 100));
4011
+ }
4012
+ // Timed out — DOM may still be loading, proceed anyway
4013
+ }
4014
+
4015
+ async function waitForScreenshotReady(page: any): Promise<void> {
4016
+ try {
4017
+ await page.waitForLoadState('domcontentloaded', { timeout: 5_000 }).catch(() => { });
4018
+ await page.waitForLoadState('networkidle', { timeout: 12_000 }).catch(() => { });
4019
+ await page.evaluate(() => {
4020
+ if (document.readyState === 'complete') return true;
4021
+ return new Promise<boolean>((resolve) => {
4022
+ const done = () => resolve(true);
4023
+ window.addEventListener('load', done, { once: true });
4024
+ setTimeout(done, 4_000);
4025
+ });
4026
+ }).catch(() => { });
4027
+ await page.evaluate(async () => {
4028
+ const fonts = (document as any).fonts;
4029
+ if (fonts?.ready) await fonts.ready;
4030
+ }).catch(() => { });
4031
+ await scrollToTriggerLazyLoad(page);
4032
+ await waitForStableDOM(page, 10_000, 700);
4033
+ await page.evaluate(async () => {
4034
+ const pending = Array.from(document.images).filter((img) => !img.complete);
4035
+ if (pending.length === 0) return;
4036
+ await Promise.race([
4037
+ Promise.all(pending.map((img) => new Promise<void>((resolve) => {
4038
+ img.addEventListener('load', () => resolve(), { once: true });
4039
+ img.addEventListener('error', () => resolve(), { once: true });
4040
+ }))),
4041
+ new Promise<void>((resolve) => setTimeout(resolve, 4_000)),
4042
+ ]);
4043
+ }).catch(() => { });
4044
+ await waitForStableDOM(page, 4_000, 500);
4045
+ await page.waitForTimeout(250).catch(() => { });
4046
+ } catch { /* readiness is best-effort; never fail the crawl for a continuously mutating page */ }
4047
+ }
4048
+
4049
+ async function hasMeaningfulAuthOrPublicState(page: any, requestedUrl: string): Promise<boolean> {
4050
+ const pathLooksRelevant = (() => {
4051
+ try {
4052
+ const pathname = new URL(page.url?.() ?? requestedUrl).pathname;
4053
+ return AUTH_PATH_RE.test(pathname) || PUBLIC_PATH_RE.test(pathname);
4054
+ } catch {
4055
+ return false;
4056
+ }
4057
+ })();
4058
+
4059
+ const domLooksRelevant = await page.evaluate(() => {
4060
+ const text = (document.body?.innerText ?? '').toLowerCase();
4061
+ const inputs = Array.from(document.querySelectorAll('input')) as HTMLInputElement[];
4062
+ const hasPassword = inputs.some((i) => i.type === 'password');
4063
+ const hasIdentityInput = inputs.some((i) => {
4064
+ const meta = `${i.type} ${i.name} ${i.id} ${i.placeholder} ${i.autocomplete}`.toLowerCase();
4065
+ return /(email|username|user|login|phone|otp|code|password)/.test(meta);
4066
+ });
4067
+ const hasAuthCopy = /(sign in|log in|login|sign up|create account|register|forgot password|reset password|verify email|one-time|otp|continue with email)/i.test(text);
4068
+ const hasPublicNav = /(pricing|contact|features|documentation|docs|about|privacy|terms)/i.test(text);
4069
+ return hasPassword || hasIdentityInput || hasAuthCopy || hasPublicNav;
4070
+ }).catch(() => false);
4071
+
4072
+ return pathLooksRelevant || domLooksRelevant;
4073
+ }
4074
+
4075
+ async function discoverAuthCandidates(page: any, origin: string): Promise<{
4076
+ links: Array<{ href: string; label: string; flowType: AuthFlowType }>;
4077
+ clicks: Array<{ selector: string; label: string; flowType: AuthFlowType }>;
4078
+ }> {
4079
+ return page.evaluate((siteOrigin: string) => {
4080
+ type BrowserFlowType = 'public' | 'login' | 'register' | 'password_reset' | 'verification' | 'invite' | 'session_recovery';
4081
+ const authTextRe = /(sign\s*in|log\s*in|login|sign\s*up|signup|register|create account|forgot password|reset password|use email|continue with email|back to login|verify|invite|get started|start free trial)/i;
4082
+ const skipTextRe = /(logout|log out|sign out|delete|remove|discard|cancel subscription|payment|billing)/i;
4083
+ const authPathRe = /\/(login|log-in|signin|sign-in|auth|oauth|sso|account\/login|user\/login|app\/login|register|signup|sign-up|create-account|forgot|reset|password|verify-email|invite)(\/|$|\?)/i;
4084
+ const publicPathRe = /\/($|pricing|contact|about|help|docs|features|security|privacy|terms)(\/|$|\?)/i;
4085
+ const sameSiteBrowser = (href: string) => {
4086
+ try {
4087
+ const u = new URL(href, location.href);
4088
+ const s = new URL(siteOrigin);
4089
+ const strip = (h: string) => h.replace(/^www\./i, '');
4090
+ return strip(u.hostname) === strip(s.hostname);
4091
+ } catch {
4092
+ return false;
4093
+ }
4094
+ };
4095
+ const flowTypeFor = (value: string): BrowserFlowType => {
4096
+ const v = value.toLowerCase();
4097
+ if (/(forgot|reset|password)/.test(v)) return 'password_reset';
4098
+ if (/(register|signup|sign up|create account|join)/.test(v)) return 'register';
4099
+ if (/(verify|verification|otp|mfa|2fa|invite)/.test(v)) return v.includes('invite') ? 'invite' : 'verification';
4100
+ if (/(session-expired|expired|unauthorized|forbidden|401|403|logout|logged-out)/.test(v)) return 'session_recovery';
4101
+ if (/(login|log in|signin|sign in|auth|sso|oauth)/.test(v)) return 'login';
4102
+ return 'public';
4103
+ };
4104
+ const textOf = (el: Element) => (
4105
+ el.textContent ||
4106
+ el.getAttribute('aria-label') ||
4107
+ el.getAttribute('title') ||
4108
+ el.getAttribute('placeholder') ||
4109
+ ''
4110
+ ).replace(/\s+/g, ' ').trim();
4111
+ const isVisible = (el: Element) => {
4112
+ const rect = (el as HTMLElement).getBoundingClientRect?.();
4113
+ const style = window.getComputedStyle(el as HTMLElement);
4114
+ return !!rect && rect.width > 0 && rect.height > 0 && style.display !== 'none' && style.visibility !== 'hidden' && style.opacity !== '0';
4115
+ };
4116
+ const links = new Map<string, { href: string; label: string; flowType: BrowserFlowType }>();
4117
+ const addHref = (hrefRaw: string | null, labelRaw: string) => {
4118
+ if (!hrefRaw || /^(javascript|mailto|tel):/i.test(hrefRaw)) return;
4119
+ let href = '';
4120
+ try { href = new URL(hrefRaw, location.href).toString(); } catch { return; }
4121
+ if (!sameSiteBrowser(href)) return;
4122
+ const u = new URL(href);
4123
+ const label = labelRaw || u.pathname;
4124
+ if (skipTextRe.test(label) || skipTextRe.test(u.pathname)) return;
4125
+ if (!authTextRe.test(label) && !authPathRe.test(u.pathname) && !publicPathRe.test(u.pathname)) return;
4126
+ const key = `${u.origin}${u.pathname.replace(/\/$/, '') || '/'}${u.search}`;
4127
+ if (!links.has(key)) links.set(key, { href, label, flowType: flowTypeFor(`${label} ${u.pathname}`) });
4128
+ };
4129
+
4130
+ const walk = (root: Document | ShadowRoot) => {
4131
+ root.querySelectorAll('a[href]').forEach((el) => addHref((el as HTMLAnchorElement).href || el.getAttribute('href'), textOf(el)));
4132
+ root.querySelectorAll('[data-href],[data-url],[data-link]').forEach((el: any) => addHref(el.dataset?.href || el.dataset?.url || el.dataset?.link || '', textOf(el)));
4133
+ root.querySelectorAll('*').forEach((el: any) => { if (el.shadowRoot) walk(el.shadowRoot); });
4134
+ };
4135
+ walk(document);
4136
+
4137
+ const clicks: Array<{ selector: string; label: string; flowType: BrowserFlowType }> = [];
4138
+ const clickEls = Array.from(document.querySelectorAll('button,[role="button"],a:not([href]),[data-state],[aria-haspopup]')) as HTMLElement[];
4139
+ let idx = 0;
4140
+ for (const el of clickEls) {
4141
+ if (clicks.length >= 12) break;
4142
+ if (!isVisible(el)) continue;
4143
+ const label = textOf(el);
4144
+ if (!label || !authTextRe.test(label) || skipTextRe.test(label)) continue;
4145
+ const type = (el.getAttribute('type') || '').toLowerCase();
4146
+ const inCredentialForm = !!el.closest('form')?.querySelector('input[type="password"],input[type="email"],input[name*="user" i],input[name*="email" i]');
4147
+ const submitLike = type === 'submit' || /^(login|log in|sign in|continue)$/i.test(label);
4148
+ // Avoid submitting empty login forms. Links/buttons that change auth state, such as
4149
+ // "Forgot password" or "Create account", are safe to click even inside the form.
4150
+ if (inCredentialForm && submitLike && !/(forgot|reset|register|sign up|create account|back to login|use email)/i.test(label)) continue;
4151
+ const attr = `zeta-auth-${idx++}`;
4152
+ el.setAttribute('data-zeta-auth-discovery-id', attr);
4153
+ clicks.push({ selector: `[data-zeta-auth-discovery-id="${attr}"]`, label, flowType: flowTypeFor(label) });
4154
+ }
4155
+
4156
+ return { links: Array.from(links.values()).slice(0, 32), clicks };
4157
+ }, origin).catch(() => ({ links: [], clicks: [] }));
4158
+ }
4159
+
4160
+ async function captureAuthDiscoveryScreen(
4161
+ page: any,
4162
+ opts: AuthDiscoveryOptions,
4163
+ requestedUrl: string,
4164
+ source: AuthDiscoverySource,
4165
+ ordinal: number,
4166
+ trigger?: { label?: string; flowType?: AuthFlowType; sameUrlState?: boolean },
4167
+ ): Promise<{ captured: boolean; finalUrl: string; flowType: AuthFlowType }> {
4168
+ const finalUrl = page.url?.() ?? requestedUrl;
4169
+ if (finalUrl.startsWith('chrome-error://')) return { captured: false, finalUrl, flowType: 'public' };
4170
+ if (!(await hasMeaningfulAuthOrPublicState(page, requestedUrl))) {
4171
+ return { captured: false, finalUrl, flowType: flowTypeForUrlOrText(`${requestedUrl} ${finalUrl} ${trigger?.label ?? ''}`) };
4172
+ }
4173
+
4174
+ const flowType = trigger?.flowType ?? flowTypeForUrlOrText(`${requestedUrl} ${finalUrl} ${trigger?.label ?? ''}`);
4175
+ const title = await page.title().catch(() => '');
4176
+ const baseName = title?.trim() && !/^(untitled|about:blank)$/i.test(title.trim())
4177
+ ? title.trim().slice(0, 72)
4178
+ : titleFromUrl(finalUrl);
4179
+ const prefix = flowType === 'public' ? 'Public' : `Auth ${flowType.replace('_', ' ')}`;
4180
+ const name = `${prefix} · ${trigger?.label ? trigger.label.slice(0, 48) : baseName}`.replace(/\s+/g, ' ');
4181
+ const screenUrl = trigger?.sameUrlState
4182
+ ? `${finalUrl.split('#')[0]}#zeta-auth-flow=${compactSlug(trigger.label ?? `${flowType}-${ordinal}`)}`
4183
+ : finalUrl;
4184
+ const shotPath = path.join(opts.screenshotDir ?? '/tmp', `auth-discovery-${opts.jobId ?? 'run'}-${ordinal}.png`);
4185
+
4186
+ await waitForScreenshotReady(page);
4187
+ await page.screenshot({ path: shotPath, fullPage: true }).catch(() => { });
4188
+ const screen = await AppBrain.saveScreen(opts.tenantId, opts.projectId, name, screenUrl, opts.jobId, source === 'entry' ? 'ENTRY_POINT' : 'SEED_URL').catch(() => null);
4189
+ if (!screen) return { captured: false, finalUrl, flowType };
4190
+ await AppBrain.saveScreenshot(opts.tenantId, opts.projectId, screen.id, shotPath, screen.version, 'DESKTOP').catch(() => { });
4191
+ await AppBrain.saveCrawlEvidence(opts.tenantId, opts.projectId, {
4192
+ screenId: screen.id,
4193
+ jobId: opts.jobId,
4194
+ type: 'AUTH_DISCOVERY',
4195
+ payload: {
4196
+ requestedUrl,
4197
+ finalUrl,
4198
+ screenUrl,
4199
+ authState: flowType === 'public' ? 'logged_out' : 'auth_flow',
4200
+ flowType,
4201
+ source,
4202
+ triggerLabel: trigger?.label ?? null,
4203
+ },
4204
+ }).catch(() => { });
4205
+ logger.info({ requestedUrl, finalUrl, screenUrl, flowType, source }, '[crawler] auth-discovery: captured screen');
4206
+ return { captured: true, finalUrl, flowType };
4207
+ }
4208
+
4209
+ async function runAuthDiscoveryPhase(
4210
+ page: any,
4211
+ opts: AuthDiscoveryOptions,
4212
+ origin: string,
4213
+ onProgress?: AuthDiscoveryProgress,
4214
+ ): Promise<{ captured: number; discoveredUrls: string[] }> {
4215
+ const queue: Array<{ url: string; source: AuthDiscoverySource }> = [];
4216
+ const queued = new Set<string>();
4217
+ const capturedKeys = new Set<string>();
4218
+ const discoveredUrls = new Set<string>();
4219
+ const enqueue = (url: string, source: AuthDiscoverySource) => {
4220
+ if (!shouldSeedAuthDiscoveryUrl(url, origin)) return;
4221
+ const key = normalizeAuthUrl(url);
4222
+ if (queued.has(key) || queue.length >= AUTH_DISCOVERY_MAX_QUEUE) return;
4223
+ queued.add(key);
4224
+ queue.push({ url, source });
4225
+ };
4226
+
4227
+ enqueue(opts.appUrl, 'entry');
4228
+ for (const p of AUTH_DISCOVERY_PATHS) {
4229
+ try { enqueue(new URL(p, origin).toString(), 'seed_path'); } catch { /* ignore bad seed */ }
4230
+ }
4231
+
4232
+ let captured = 0;
4233
+ let ordinal = 0;
4234
+ await onProgress?.(10, 'Discovering public and auth flows');
4235
+
4236
+ while (queue.length > 0 && captured < AUTH_DISCOVERY_MAX_SCREENS) {
4237
+ const current = queue.shift()!;
4238
+ try {
4239
+ const response = await page.goto(current.url, { waitUntil: 'domcontentloaded', timeout: 15_000 }).catch(() => null);
4240
+ if (response && response.status && response.status() >= 400) continue;
4241
+ await page.waitForLoadState('networkidle', { timeout: 6_000 }).catch(() => { });
4242
+ await waitForStableDOM(page);
4243
+ await acceptCookieConsent(page, { waitMs: 400 }).catch(() => { });
4244
+
4245
+ const finalUrl = page.url?.() ?? current.url;
4246
+ const captureKey = normalizeAuthUrl(finalUrl);
4247
+ if (!capturedKeys.has(captureKey)) {
4248
+ const result = await captureAuthDiscoveryScreen(page, opts, current.url, current.source, ordinal++);
4249
+ if (result.captured) {
4250
+ captured++;
4251
+ capturedKeys.add(captureKey);
4252
+ discoveredUrls.add(result.finalUrl);
4253
+ }
4254
+ }
4255
+
4256
+ const candidates = await discoverAuthCandidates(page, origin);
4257
+ for (const link of candidates.links) enqueue(link.href, 'page_link');
4258
+
4259
+ const baseUrlForClicks = finalUrl;
4260
+ const clickLimit = Math.min(candidates.clicks.length, AUTH_DISCOVERY_MAX_CLICKS_PER_PAGE);
4261
+ for (let i = 0; i < clickLimit && captured < AUTH_DISCOVERY_MAX_SCREENS; i++) {
4262
+ await page.goto(baseUrlForClicks, { waitUntil: 'domcontentloaded', timeout: 12_000 }).catch(() => { });
4263
+ await page.waitForLoadState('networkidle', { timeout: 4_000 }).catch(() => { });
4264
+ await waitForStableDOM(page);
4265
+ const freshCandidates = await discoverAuthCandidates(page, origin);
4266
+ const click = freshCandidates.clicks[i];
4267
+ if (!click) continue;
4268
+ const beforeUrl = page.url?.() ?? baseUrlForClicks;
4269
+ await page.locator(click.selector).first().click({ timeout: 4_000 }).catch(() => { });
4270
+ await page.waitForLoadState('networkidle', { timeout: 5_000 }).catch(() => { });
4271
+ await waitForStableDOM(page);
4272
+ const afterUrl = page.url?.() ?? beforeUrl;
4273
+ const sameUrlState = normalizeAuthUrl(beforeUrl) === normalizeAuthUrl(afterUrl);
4274
+ const stateKey = sameUrlState
4275
+ ? `${normalizeAuthUrl(afterUrl)}::${compactSlug(click.label)}`
4276
+ : normalizeAuthUrl(afterUrl);
4277
+ if (capturedKeys.has(stateKey)) continue;
4278
+ const result = await captureAuthDiscoveryScreen(page, opts, beforeUrl, 'auth_click', ordinal++, {
4279
+ label: click.label,
4280
+ flowType: click.flowType,
4281
+ sameUrlState,
4282
+ });
4283
+ if (result.captured) {
4284
+ captured++;
4285
+ capturedKeys.add(stateKey);
4286
+ discoveredUrls.add(result.finalUrl);
4287
+ const nested = await discoverAuthCandidates(page, origin);
4288
+ for (const link of nested.links) enqueue(link.href, 'page_link');
4289
+ }
4290
+ }
4291
+ } catch (err) {
4292
+ logger.warn({ url: current.url, err: String((err as Error)?.message ?? err) }, '[crawler] auth-discovery: URL failed — skipping');
4293
+ }
4294
+ }
4295
+
4296
+ await page.goto(opts.appUrl, { waitUntil: 'domcontentloaded', timeout: 15_000 }).catch(() => { });
4297
+ await waitForStableDOM(page).catch(() => { });
4298
+ await AppBrain.saveCrawlEvidence(opts.tenantId, opts.projectId, {
4299
+ jobId: opts.jobId,
4300
+ type: 'AUTH_DISCOVERY_SUMMARY',
4301
+ payload: { captured, discoveredUrls: Array.from(discoveredUrls), maxScreens: AUTH_DISCOVERY_MAX_SCREENS },
4302
+ }).catch(() => { });
4303
+ logger.info({ captured, discovered: discoveredUrls.size }, '[crawler] auth-discovery: phase complete');
4304
+ return { captured, discoveredUrls: Array.from(discoveredUrls) };
4305
+ }