@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
@@ -0,0 +1,3672 @@
1
+ import { Stagehand } from "@browserbasehq/stagehand";
2
+ import { chromium } from "rebrowser-playwright-core";
3
+ import { chromium as stealthChromium } from "playwright-extra";
4
+ import StealthPlugin from "puppeteer-extra-plugin-stealth";
5
+ import { execSync } from "node:child_process";
6
+ import { readFileSync } from "node:fs";
7
+ import { createRequire } from "node:module";
8
+ stealthChromium.use(StealthPlugin());
9
+ import { z } from "zod";
10
+ import fs from "node:fs/promises";
11
+ import path from "node:path";
12
+ import { AppBrain, recordLLMCall } from "@qip/app-brain";
13
+ import { aiVisualCompare, askTracked, estimateCost, getTaskAgentLLMConfig } from "@qip/agents";
14
+ import { config } from "@qip/core";
15
+ import { pHashVerdict, mergeAiVisualVerdict } from "./visual-diff-verdict.js";
16
+ import { skyvernFallback, skyvernNavigate } from "./skyvern.js";
17
+ import { loginWithStagehand } from "./ai-login.js";
18
+ import { readScreen } from "./screen-reader.js";
19
+ import { probeAllScreens } from "./interactive-probe.js";
20
+ import { computeElementHash } from "./element-hash.js";
21
+ import { logger } from "./logger.js";
22
+ import { getSsoToken } from "./sso-token.js";
23
+ import { discoverSitemapUrls, sameSite } from "./sitemap.js";
24
+ import { extractMarkdown } from "./markdown-extract.js";
25
+ import { CrawlFrontier } from "./frontier.js";
26
+ import { RobotsCache } from "./robots-txt.js";
27
+ import { classifyDomainProfile } from "./profiles/domain-profile.js";
28
+ import { chooseCrawlEngines } from "./engine-router.js";
29
+ import { assessActionSafety } from "./policies/crawl-safety.js";
30
+ import { attachNetworkObserver } from "./network-observer.js";
31
+ import { scrubPii } from "./pii-scrubber.js";
32
+ import { attachWebSocketDetector } from "./websocket-detector.js";
33
+ import { ContentDeduplicator } from "./content-dedup.js";
34
+ import { guessGraphQLEndpoints, introspectGraphQL } from "./graphql-detector.js";
35
+ import { acceptCookieConsent } from "./cookie-consent.js";
36
+ import { detectJsRoutes } from "./js-router-detector.js";
37
+ import { runGapAnalysis } from "./gap-pass.js";
38
+ import { buildCrawlArtifactEnvelope, buildFitMarkdown, sha256, summarizeStructuredElements } from "./extractors/artifact-envelope.js";
39
+ import { pickFreeProxy, TieredProxyManager } from "./proxy.js";
40
+ import { infiniteScrollToBottom, extractIframeContent, pierceShadowDom, injectFingerprintOverrides } from "./stealth/human-behavior.js";
41
+ import { checkSeo } from "./seo-checker.js";
42
+ import { auditColorContrast } from "./color-contrast.js";
43
+ import { detectFeatureFlags } from "./feature-flag-detector.js";
44
+ import { detectLanguage } from "./language-detector.js";
45
+ import { detectForms } from "./form-detector.js";
46
+ import { RedirectRegistry } from "./redirect-tracker.js";
47
+ import { DeadLinkTracker } from "./dead-link-detector.js";
48
+ import { screensToSitemapUrls, writeSitemap } from "./sitemap-writer.js";
49
+ import { generateOpenApiSpec, specToYaml } from "./openapi-generator.js";
50
+ import { WebhookNotifier } from "./webhook-notifier.js";
51
+ import { generatePlaywrightTest } from "./playwright-recorder.js";
52
+ function getGitInfo() {
53
+ try {
54
+ const branch = execSync("git rev-parse --abbrev-ref HEAD", { encoding: "utf8", timeout: 3e3 }).trim();
55
+ const commit = execSync("git rev-parse HEAD", { encoding: "utf8", timeout: 3e3 }).trim().slice(0, 12);
56
+ return { branch: branch || "main", commit };
57
+ } catch {
58
+ return { branch: "main", commit: "" };
59
+ }
60
+ }
61
+ const ALLOWED_NUMERIC_PATHS = /* @__PURE__ */ new Set(["/2fa", "/404", "/500", "/403", "/401"]);
62
+ 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;
63
+ const CUID_PATH_SEGMENT_RE = /^c[a-z0-9]{20,}$/i;
64
+ const NUMERIC_ID_PATH_SEGMENT_RE = /^\d[\d-]*$/;
65
+ const COMPOUND_NUMERIC_ID_PATH_SEGMENT_RE = /^\d+-\d+$/;
66
+ function normalizePathForNumericGuard(pathname) {
67
+ const normalized = pathname.replace(/\/+$/, "") || "/";
68
+ return normalized;
69
+ }
70
+ function isGeneratedPathSegment(segment) {
71
+ return UUID_PATH_SEGMENT_RE.test(segment) || CUID_PATH_SEGMENT_RE.test(segment) || COMPOUND_NUMERIC_ID_PATH_SEGMENT_RE.test(segment) || NUMERIC_ID_PATH_SEGMENT_RE.test(segment);
72
+ }
73
+ function isGeneratedDynamicPath(pathname) {
74
+ const normalized = normalizePathForNumericGuard(pathname);
75
+ if (ALLOWED_NUMERIC_PATHS.has(normalized)) return false;
76
+ return normalized.split("/").filter(Boolean).some(isGeneratedPathSegment);
77
+ }
78
+ function isUsefulNavigationUrl(href, siteOrigin) {
79
+ try {
80
+ const u = new URL(href);
81
+ if (!sameSite(u.toString(), siteOrigin)) return false;
82
+ if (/\/(logout|logged-out|signout|sign-out)(\/|$|\?)/i.test(u.pathname)) return false;
83
+ if (isGeneratedDynamicPath(u.pathname)) return false;
84
+ return true;
85
+ } catch {
86
+ return false;
87
+ }
88
+ }
89
+ const AUTH_DISCOVERY_MAX_SCREENS = 36;
90
+ const AUTH_DISCOVERY_MAX_QUEUE = 48;
91
+ const AUTH_DISCOVERY_MAX_CLICKS_PER_PAGE = 6;
92
+ const AUTH_DISCOVERY_PATHS = [
93
+ "/",
94
+ "/login",
95
+ "/log-in",
96
+ "/signin",
97
+ "/sign-in",
98
+ "/auth",
99
+ "/auth/login",
100
+ "/account/login",
101
+ "/user/login",
102
+ "/app/login",
103
+ "/register",
104
+ "/signup",
105
+ "/sign-up",
106
+ "/create-account",
107
+ "/join",
108
+ "/forgot-password",
109
+ "/forgot",
110
+ "/password/forgot",
111
+ "/reset-password",
112
+ "/password/reset",
113
+ "/verify-email",
114
+ "/invite",
115
+ "/pricing",
116
+ "/contact",
117
+ "/about",
118
+ "/help",
119
+ "/docs"
120
+ ];
121
+ 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;
122
+ const PUBLIC_PATH_RE = /\/($|pricing|contact|about|help|docs|features|security|privacy|terms)(\/|$|\?)/i;
123
+ function compactSlug(input, fallback = "state") {
124
+ const slug = input.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 48);
125
+ return slug || fallback;
126
+ }
127
+ function titleFromUrl(url, fallback = "Page") {
128
+ try {
129
+ const u = new URL(url);
130
+ const parts = u.pathname.split("/").filter(Boolean);
131
+ if (parts.length === 0) return "Home";
132
+ return parts.slice(-2).join(" ").replace(/[-_]+/g, " ").replace(/\b\w/g, (m) => m.toUpperCase());
133
+ } catch {
134
+ return fallback;
135
+ }
136
+ }
137
+ function flowTypeForUrlOrText(value) {
138
+ const v = value.toLowerCase();
139
+ if (/(forgot|reset|password)/.test(v)) return "password_reset";
140
+ if (/(register|signup|sign up|create account|join)/.test(v)) return "register";
141
+ if (/(verify|verification|otp|mfa|2fa|invite)/.test(v)) return v.includes("invite") ? "invite" : "verification";
142
+ if (/(session-expired|expired|unauthorized|forbidden|401|403|logout|logged-out)/.test(v)) return "session_recovery";
143
+ if (/(login|log in|signin|sign in|auth|sso|oauth)/.test(v)) return "login";
144
+ return "public";
145
+ }
146
+ function shouldSeedAuthDiscoveryUrl(url, origin) {
147
+ try {
148
+ const u = new URL(url);
149
+ if (!sameSite(u.toString(), origin)) return false;
150
+ if (/\/(logout|logged-out|signout|sign-out|delete|remove|destroy)(\/|$|\?)/i.test(u.pathname)) return false;
151
+ return AUTH_PATH_RE.test(u.pathname) || PUBLIC_PATH_RE.test(u.pathname);
152
+ } catch {
153
+ return false;
154
+ }
155
+ }
156
+ function normalizeAuthUrl(url) {
157
+ try {
158
+ const u = new URL(url);
159
+ u.hash = "";
160
+ return `${u.origin}${u.pathname.replace(/\/$/, "") || "/"}${u.search}`;
161
+ } catch {
162
+ return url;
163
+ }
164
+ }
165
+ async function capturePageHtml(page) {
166
+ try {
167
+ if (typeof page.content === "function") {
168
+ const html = await page.content();
169
+ if (typeof html === "string" && html.trim()) return html;
170
+ }
171
+ } catch (err) {
172
+ logger.warn({ err: String(err?.message ?? err) }, "[crawler] page.content() failed; trying DOM evaluate fallback");
173
+ }
174
+ try {
175
+ const html = await page.evaluate(() => {
176
+ const type = document.doctype;
177
+ const doctype = type ? "<!DOCTYPE " + type.name + (type.publicId ? ' PUBLIC "' + type.publicId + '"' : "") + (type.systemId ? ' "' + type.systemId + '"' : "") + ">" : "<!DOCTYPE html>";
178
+ return doctype + "\n" + (document.documentElement?.outerHTML ?? document.body?.outerHTML ?? "");
179
+ });
180
+ return typeof html === "string" && html.trim() ? html : null;
181
+ } catch (err) {
182
+ logger.warn({ err: String(err?.message ?? err) }, "[crawler] DOM snapshot capture failed");
183
+ return null;
184
+ }
185
+ }
186
+ async function launchChromeCDP(execPath, extraArgs, proxy) {
187
+ const port = 9200 + Math.floor(Math.random() * 700);
188
+ const browserServer = await stealthChromium.launchServer({
189
+ headless: true,
190
+ executablePath: execPath,
191
+ // Use Playwright's native proxy option — it handles 407 auth challenges automatically.
192
+ // The --proxy-server Chrome flag does NOT support credentials with special chars (commas).
193
+ ...proxy ? { proxy } : {},
194
+ args: [
195
+ ...extraArgs,
196
+ `--remote-debugging-port=${port}`,
197
+ "--remote-allow-origins=*",
198
+ "--no-first-run",
199
+ "--no-default-browser-check"
200
+ ]
201
+ });
202
+ const deadline = Date.now() + 1e4;
203
+ while (Date.now() < deadline) {
204
+ await new Promise((r) => setTimeout(r, 300));
205
+ try {
206
+ const res = await fetch(`http://127.0.0.1:${port}/json/version`);
207
+ if (res.ok) {
208
+ const { webSocketDebuggerUrl } = await res.json();
209
+ return { wsUrl: webSocketDebuggerUrl, proc: browserServer };
210
+ }
211
+ } catch {
212
+ }
213
+ }
214
+ await browserServer.close();
215
+ throw new Error(`Chrome failed to open CDP port ${port} within 10 s`);
216
+ }
217
+ async function discoverLinksViaBrowser(appUrl, opts) {
218
+ let proxy;
219
+ const purl = opts?.proxyUrl;
220
+ if (purl && purl !== "auto") {
221
+ const m = purl.match(/^(https?|socks5?):\/\/(?:([^:@]*)(?::([^@]*))?@)?(.+)$/);
222
+ if (m) {
223
+ const [, scheme, user, pass, hostPort] = m;
224
+ proxy = user ? { server: `${scheme}://${hostPort}`, username: decodeURIComponent(user), password: decodeURIComponent(pass ?? "") } : { server: `${scheme}://${hostPort}` };
225
+ } else {
226
+ proxy = { server: purl };
227
+ }
228
+ }
229
+ let executablePath;
230
+ try {
231
+ executablePath = chromium.executablePath();
232
+ } catch {
233
+ }
234
+ let browser = null;
235
+ try {
236
+ browser = await chromium.launch({
237
+ headless: true,
238
+ ...executablePath ? { executablePath } : {},
239
+ ...proxy ? { proxy } : {},
240
+ args: ["--no-sandbox", "--disable-dev-shm-usage"]
241
+ });
242
+ const page = await browser.newPage();
243
+ await page.goto(appUrl, { waitUntil: "networkidle", timeout: 3e4 }).catch(() => {
244
+ });
245
+ await page.waitForTimeout(2500);
246
+ const hrefs = await page.evaluate((origin) => {
247
+ const found = /* @__PURE__ */ new Set();
248
+ const walk = (root) => {
249
+ root.querySelectorAll("a[href]").forEach((a) => {
250
+ const h = a.href || a.getAttribute("href") || "";
251
+ if (h && (h.startsWith(origin) || h.startsWith("/"))) found.add(h);
252
+ });
253
+ root.querySelectorAll("*").forEach((el) => {
254
+ if (el.shadowRoot) walk(el.shadowRoot);
255
+ });
256
+ };
257
+ walk(document);
258
+ return [...found];
259
+ }, new URL(appUrl).origin).catch(() => []);
260
+ return [...new Set(hrefs.filter((h) => sameSite(h, appUrl)))].slice(0, opts?.maxLinks ?? 500);
261
+ } catch {
262
+ return [];
263
+ } finally {
264
+ if (browser) await browser.close().catch(() => {
265
+ });
266
+ }
267
+ }
268
+ async function runLighthouse(url) {
269
+ if (!process.env.ENABLE_LIGHTHOUSE) return null;
270
+ try {
271
+ const { default: lighthouse } = await import("lighthouse");
272
+ const chromeLauncher = await import("chrome-launcher");
273
+ const chrome = await chromeLauncher.launch({
274
+ chromeFlags: ["--headless", "--no-sandbox", "--disable-gpu", "--disable-dev-shm-usage"]
275
+ });
276
+ try {
277
+ const result = await lighthouse(url, {
278
+ port: chrome.port,
279
+ output: "json",
280
+ logLevel: "error",
281
+ onlyCategories: ["performance", "accessibility", "seo", "best-practices"]
282
+ });
283
+ if (!result?.lhr?.categories) return null;
284
+ const cats = result.lhr.categories;
285
+ const score = (key) => Math.round((cats[key]?.score ?? 0) * 100);
286
+ return {
287
+ perf: score("performance"),
288
+ a11y: score("accessibility"),
289
+ seo: score("seo"),
290
+ bp: score("best-practices"),
291
+ report: result.lhr
292
+ };
293
+ } finally {
294
+ await chrome.kill();
295
+ }
296
+ } catch (err) {
297
+ console.warn("[crawler] Lighthouse run failed (non-fatal):", err);
298
+ return null;
299
+ }
300
+ }
301
+ const ElementSchema = z.object({
302
+ elements: z.array(z.object({
303
+ meaning: z.string(),
304
+ role: z.string(),
305
+ expectedData: z.string().optional()
306
+ }))
307
+ });
308
+ const LinksSchema = z.object({
309
+ links: z.array(z.string())
310
+ });
311
+ const TwoFASchema = z.object({
312
+ has2FA: z.boolean(),
313
+ fieldLabel: z.string().optional()
314
+ });
315
+ async function crawlProject(opts, onProgress) {
316
+ if (opts.screenshotDir) await fs.mkdir(opts.screenshotDir, { recursive: true });
317
+ const isDocker = !!process.env.PLAYWRIGHT_IN_DOCKER || process.env.NODE_ENV === "production";
318
+ const toStagehandModel = (model, provider) => model.includes("/") ? model : `${provider}/${model}`;
319
+ let stagehandModel = toStagehandModel(config.llmModel, "openai");
320
+ let stagehandApiKey = "";
321
+ let configuredProvider = "";
322
+ try {
323
+ const crawlLLM = await getTaskAgentLLMConfig(opts.tenantId, "crawl");
324
+ if (crawlLLM) {
325
+ configuredProvider = crawlLLM.provider;
326
+ if (crawlLLM.apiKey && crawlLLM.model) {
327
+ if (crawlLLM.provider === "anthropic" || crawlLLM.provider === "openai") {
328
+ stagehandModel = toStagehandModel(crawlLLM.model, crawlLLM.provider);
329
+ stagehandApiKey = crawlLLM.apiKey;
330
+ } else if (crawlLLM.provider === "openrouter") {
331
+ const model = crawlLLM.model.includes("/") ? crawlLLM.model : `openai/${crawlLLM.model}`;
332
+ stagehandModel = model;
333
+ stagehandApiKey = crawlLLM.apiKey;
334
+ }
335
+ }
336
+ }
337
+ } catch {
338
+ }
339
+ if (!stagehandApiKey) {
340
+ const envModel = process.env.STAGEHAND_MODEL ?? "";
341
+ const preferAnthropic = envModel.includes("claude") || !envModel && !process.env.OPENAI_API_KEY;
342
+ if (preferAnthropic && process.env.ANTHROPIC_API_KEY) {
343
+ stagehandApiKey = process.env.ANTHROPIC_API_KEY;
344
+ stagehandModel = envModel.includes("/") ? envModel : `anthropic/${envModel || "claude-sonnet-4-6"}`;
345
+ } else if (process.env.OPENAI_API_KEY) {
346
+ stagehandApiKey = process.env.OPENAI_API_KEY;
347
+ stagehandModel = envModel.includes("/") ? envModel : `openai/${envModel || "gpt-4o"}`;
348
+ } else if (process.env.ANTHROPIC_API_KEY) {
349
+ stagehandApiKey = process.env.ANTHROPIC_API_KEY;
350
+ stagehandModel = envModel.includes("/") ? envModel : `anthropic/${envModel || "claude-sonnet-4-6"}`;
351
+ }
352
+ }
353
+ const elementLLM = await AppBrain.getTaskLLMConfig(opts.tenantId, "elementExtraction").catch(() => null);
354
+ const linkLLM = await AppBrain.getTaskLLMConfig(opts.tenantId, "linkDiscovery").catch(() => null);
355
+ const projectForProfile = await AppBrain.getProjectMap(opts.tenantId, opts.projectId).catch(() => null);
356
+ const profileProjectName = projectForProfile?.name ?? null;
357
+ const profileAppType = projectForProfile?.appType ?? null;
358
+ const domainProfile = classifyDomainProfile({
359
+ appUrl: opts.appUrl,
360
+ projectName: profileProjectName,
361
+ appType: profileAppType,
362
+ seedText: [
363
+ ...opts.startUrls ?? [],
364
+ opts.loginInstructions ?? "",
365
+ ...opts.bestFirstKeywords ?? []
366
+ ].join(" ")
367
+ });
368
+ const routeDecision = chooseCrawlEngines({
369
+ appUrl: opts.appUrl,
370
+ appType: profileAppType,
371
+ hasCredentials: !!opts.credentials,
372
+ hasStorageState: !!opts.storageState,
373
+ hasAuthToken: !!opts.authToken,
374
+ hasSkyvern: !!opts.skyvernConfig,
375
+ hasProxy: !!opts.proxyUrl,
376
+ profile: domainProfile
377
+ });
378
+ AppBrain.saveCrawlEvidence(opts.tenantId, opts.projectId, {
379
+ jobId: opts.jobId,
380
+ type: "CRAWL_PROFILE",
381
+ payload: { profile: domainProfile, routeDecision }
382
+ }).catch((err) => console.warn("[crawler] profile evidence save failed:", err?.message ?? err));
383
+ if (!stagehandApiKey) {
384
+ const unsupported = configuredProvider && !["anthropic", "openai", "openrouter"].includes(configuredProvider);
385
+ throw new Error(
386
+ unsupported ? `Browser crawling requires Anthropic or OpenAI. Your primary provider "${configuredProvider}" is not supported for crawling. Add a separate Crawl API Key in Workspace \u2192 Settings \u2192 AI Provider.` : "No AI provider configured. Go to Workspace \u2192 Settings \u2192 AI Provider to set up Anthropic or OpenAI for browser crawling."
387
+ );
388
+ }
389
+ const stealthArgs = [
390
+ "--disable-blink-features=AutomationControlled",
391
+ "--disable-http2",
392
+ "--lang=en-US,en",
393
+ "--window-size=1366,768",
394
+ "--disable-features=IsolateOrigins,site-per-process"
395
+ ];
396
+ const chromeArgs = isDocker ? [
397
+ "--no-sandbox",
398
+ "--disable-setuid-sandbox",
399
+ "--disable-dev-shm-usage",
400
+ "--disable-gpu",
401
+ ...stealthArgs
402
+ ] : [...stealthArgs];
403
+ let resolvedProxyUrl = opts.proxyUrl;
404
+ let tieredProxyManager;
405
+ if (opts.proxyTiers?.length) {
406
+ tieredProxyManager = new TieredProxyManager(opts.proxyTiers);
407
+ const domain = (() => {
408
+ try {
409
+ return new URL(opts.appUrl).hostname;
410
+ } catch {
411
+ return opts.appUrl;
412
+ }
413
+ })();
414
+ resolvedProxyUrl = tieredProxyManager.selectProxy(domain) ?? resolvedProxyUrl;
415
+ logger.info({ domain, proxyUrl: resolvedProxyUrl }, "[crawler] tiered proxy selected");
416
+ } else if (resolvedProxyUrl === "auto") {
417
+ logger.info("[crawler] proxyUrl=auto: selecting free proxy from public list...");
418
+ resolvedProxyUrl = await pickFreeProxy() ?? void 0;
419
+ if (resolvedProxyUrl) {
420
+ logger.info({ proxyUrl: resolvedProxyUrl }, "[crawler] auto-proxy selected");
421
+ } else {
422
+ logger.warn("[crawler] auto-proxy: no working proxy found, proceeding without proxy");
423
+ }
424
+ }
425
+ let playwrightProxy;
426
+ if (resolvedProxyUrl) {
427
+ try {
428
+ const proxyMatch = resolvedProxyUrl.match(/^(https?|socks5?):\/\/(?:([^:@]*)(?::([^@]*))?@)?(.+)$/);
429
+ if (proxyMatch) {
430
+ const [, scheme, rawUser, rawPass, hostPort] = proxyMatch;
431
+ const server = `${scheme}://${hostPort}`;
432
+ const username = rawUser ? decodeURIComponent(rawUser) : void 0;
433
+ const password = rawPass ? decodeURIComponent(rawPass) : void 0;
434
+ playwrightProxy = username ? { server, username, password } : { server };
435
+ logger.info({ server, hasAuth: !!username }, "[crawler] proxy configured (Playwright native)");
436
+ } else {
437
+ playwrightProxy = { server: resolvedProxyUrl };
438
+ }
439
+ } catch {
440
+ playwrightProxy = { server: resolvedProxyUrl };
441
+ }
442
+ }
443
+ let executablePath;
444
+ try {
445
+ executablePath = chromium.executablePath();
446
+ } catch {
447
+ }
448
+ if (!executablePath) throw new Error("No Chrome binary found. Set CHROME_PATH or install Playwright browsers.");
449
+ let effectiveStorageState = opts.storageState;
450
+ if (opts.chromeProfilePath && !isDocker) {
451
+ logger.info({ chromeProfilePath: opts.chromeProfilePath }, "[crawler] Exporting session from Chrome profile");
452
+ try {
453
+ const tempCtx = await chromium.launchPersistentContext(opts.chromeProfilePath, {
454
+ headless: true,
455
+ args: [...chromeArgs, "--no-first-run", "--no-default-browser-check"],
456
+ executablePath
457
+ });
458
+ const exportedState = await tempCtx.storageState();
459
+ await tempCtx.close().catch(() => {
460
+ });
461
+ if ((exportedState.cookies?.length ?? 0) > 0 || (exportedState.origins?.length ?? 0) > 0) {
462
+ effectiveStorageState = JSON.stringify(exportedState);
463
+ logger.info({ cookies: exportedState.cookies?.length ?? 0, origins: exportedState.origins?.length ?? 0 }, "[crawler] Chrome profile session exported and injected");
464
+ } else {
465
+ logger.warn("[crawler] Chrome profile had no session data \u2014 continuing without profile auth");
466
+ }
467
+ } catch (profileErr) {
468
+ logger.warn({ err: profileErr }, "[crawler] Could not load Chrome profile \u2014 continuing without profile auth");
469
+ }
470
+ } else if (opts.chromeProfilePath && isDocker) {
471
+ logger.warn("[crawler] chromeProfilePath is set but will be ignored in Docker/cloud environments");
472
+ }
473
+ if (opts.useFirefox) {
474
+ logger.info("[crawler] useFirefox: launching Camoufox to bootstrap Cloudflare cookies...");
475
+ try {
476
+ const { NewBrowser: camoufoxNewBrowser } = await import("camoufox");
477
+ const pw = require("playwright");
478
+ const foxBrowser = await camoufoxNewBrowser(pw, true);
479
+ const foxPage = await foxBrowser.newPage();
480
+ await foxPage.goto(opts.appUrl, { waitUntil: "domcontentloaded", timeout: 3e4 });
481
+ await foxPage.waitForTimeout(8e3);
482
+ const foxState = await foxPage.context().storageState();
483
+ await foxBrowser.close().catch(() => {
484
+ });
485
+ if ((foxState.cookies?.length ?? 0) > 0) {
486
+ if (effectiveStorageState) {
487
+ try {
488
+ const existing = JSON.parse(effectiveStorageState);
489
+ const merged = { ...existing, cookies: [...existing.cookies ?? [], ...foxState.cookies] };
490
+ effectiveStorageState = JSON.stringify(merged);
491
+ } catch {
492
+ effectiveStorageState = JSON.stringify(foxState);
493
+ }
494
+ } else {
495
+ effectiveStorageState = JSON.stringify(foxState);
496
+ }
497
+ logger.info({ cookies: foxState.cookies.length }, "[crawler] Camoufox bootstrap complete \u2014 cf_clearance injected into Chromium");
498
+ } else {
499
+ logger.warn("[crawler] Camoufox returned no cookies \u2014 continuing without bootstrap");
500
+ }
501
+ } catch (foxErr) {
502
+ logger.warn({ err: foxErr }, "[crawler] Camoufox bootstrap failed \u2014 continuing without it");
503
+ }
504
+ }
505
+ const { wsUrl: cdpUrl, proc: chromeProc } = await launchChromeCDP(executablePath, chromeArgs, playwrightProxy);
506
+ let stagehandPromptTokens = 0;
507
+ let stagehandCompletionTokens = 0;
508
+ const stagehandLogger = (line) => {
509
+ if (line.message === "response" && line.auxiliary?.response?.value) {
510
+ try {
511
+ const resp = JSON.parse(line.auxiliary.response.value);
512
+ if (resp?.usage) {
513
+ stagehandPromptTokens += resp.usage.prompt_tokens ?? 0;
514
+ stagehandCompletionTokens += resp.usage.completion_tokens ?? 0;
515
+ }
516
+ } catch {
517
+ }
518
+ }
519
+ };
520
+ const stagehand = new Stagehand({
521
+ env: "LOCAL",
522
+ model: { modelName: stagehandModel, apiKey: stagehandApiKey },
523
+ serverCache: true,
524
+ verbose: 1,
525
+ logger: stagehandLogger,
526
+ localBrowserLaunchOptions: { cdpUrl, headless: true },
527
+ domSettleTimeout: 2e3
528
+ });
529
+ await stagehand.init();
530
+ await onProgress?.(5, "Browser launched");
531
+ const page = await stagehand.resolvePage();
532
+ try {
533
+ page.setDefaultTimeout(45e3);
534
+ } catch {
535
+ }
536
+ try {
537
+ page.setDefaultNavigationTimeout(3e4);
538
+ } catch {
539
+ }
540
+ let rawPage = null;
541
+ await page.addInitScript(() => {
542
+ Object.defineProperty(navigator, "webdriver", { get: () => void 0 });
543
+ Object.defineProperty(navigator, "languages", { get: () => ["en-US", "en"] });
544
+ const fakePlugins = ["PDF Viewer", "Chrome PDF Viewer", "Chromium PDF Viewer", "Microsoft Edge PDF Viewer", "WebKit built-in PDF"];
545
+ Object.defineProperty(navigator, "plugins", {
546
+ get: () => Object.assign(fakePlugins.map((name, i) => ({ name, filename: "internal-pdf-viewer", description: "", length: 1, item: () => null, namedItem: () => null })), { item: (i) => null, namedItem: () => null, refresh: () => {
547
+ } })
548
+ });
549
+ window.chrome = { runtime: {}, loadTimes: () => ({}), csi: () => ({}), app: {} };
550
+ const origQuery = window.navigator.permissions?.query?.bind(navigator.permissions);
551
+ if (origQuery) {
552
+ navigator.permissions.query = (params) => params?.name === "notifications" ? Promise.resolve({ state: "prompt", onchange: null }) : origQuery(params);
553
+ }
554
+ try {
555
+ Object.defineProperty(Notification, "permission", { get: () => "default" });
556
+ } catch {
557
+ }
558
+ if (!navigator.connection) Object.defineProperty(navigator, "connection", { get: () => ({ rtt: 50, downlink: 10, effectiveType: "4g", saveData: false }) });
559
+ delete window.__playwright;
560
+ delete window.__pw_manual;
561
+ delete window.__PW_inspect;
562
+ });
563
+ await page.addInitScript(() => {
564
+ try {
565
+ window.__zetaLCP = null;
566
+ window.__zetaCLS = 0;
567
+ new PerformanceObserver((list) => {
568
+ const entries = list.getEntries();
569
+ const last = entries[entries.length - 1];
570
+ if (last) window.__zetaLCP = last.startTime;
571
+ }).observe({ type: "largest-contentful-paint", buffered: true });
572
+ new PerformanceObserver((list) => {
573
+ for (const entry of list.getEntries()) {
574
+ if (!entry.hadRecentInput) window.__zetaCLS += entry.value;
575
+ }
576
+ }).observe({ type: "layout-shift", buffered: true });
577
+ } catch {
578
+ }
579
+ });
580
+ if (opts.httpBasicUsername && opts.httpBasicPassword) {
581
+ const encoded = Buffer.from(`${opts.httpBasicUsername}:${opts.httpBasicPassword}`).toString("base64");
582
+ const appOrigin = new URL(opts.appUrl).origin;
583
+ await page.route("**/*", (route) => {
584
+ const reqUrl = route.request().url();
585
+ if (reqUrl.startsWith(appOrigin)) {
586
+ route.continue({
587
+ headers: { ...route.request().headers(), "Authorization": `Basic ${encoded}` }
588
+ });
589
+ } else {
590
+ route.continue();
591
+ }
592
+ });
593
+ }
594
+ if (opts.authToken) {
595
+ const headerName = opts.authTokenHeader ?? "Authorization";
596
+ const prefix = opts.authTokenPrefix ?? "Bearer ";
597
+ await page.setExtraHTTPHeaders({ [headerName]: `${prefix}${opts.authToken}` });
598
+ await page.addInitScript((token) => {
599
+ localStorage.setItem("auth_token", token);
600
+ localStorage.setItem("access_token", token);
601
+ localStorage.setItem("token", token);
602
+ }, opts.authToken);
603
+ }
604
+ await page.addInitScript(() => {
605
+ try {
606
+ window.__zetaCLS = 0;
607
+ new PerformanceObserver((list) => {
608
+ for (const entry of list.getEntries()) {
609
+ if (!entry.hadRecentInput) window.__zetaCLS += entry.value;
610
+ }
611
+ }).observe({ type: "layout-shift", buffered: true });
612
+ } catch {
613
+ }
614
+ });
615
+ await page.addInitScript(() => {
616
+ window.__zetaDiscoveredRoutes = window.__zetaDiscoveredRoutes ?? [];
617
+ const push = history.pushState.bind(history);
618
+ const replace = history.replaceState.bind(history);
619
+ history.pushState = function(data, unused, url) {
620
+ const result = push(data, unused, url);
621
+ if (url && typeof url === "string") window.__zetaDiscoveredRoutes.push(url);
622
+ return result;
623
+ };
624
+ history.replaceState = function(data, unused, url) {
625
+ const result = replace(data, unused, url);
626
+ if (url && typeof url === "string") window.__zetaDiscoveredRoutes.push(url);
627
+ return result;
628
+ };
629
+ });
630
+ await injectFingerprintOverrides(page).catch(() => {
631
+ });
632
+ const capturedApiEndpoints = [];
633
+ const revisitQueue = [];
634
+ const xhrDiscoveredPaths = /* @__PURE__ */ new Set();
635
+ let playwrightBrowser = null;
636
+ try {
637
+ playwrightBrowser = await chromium.connectOverCDP(cdpUrl);
638
+ const pwContext = playwrightBrowser.contexts()[0];
639
+ if (pwContext) {
640
+ attachNetworkObserver(pwContext, capturedApiEndpoints, (relPath) => xhrDiscoveredPaths.add(relPath));
641
+ pwContext.on("page", (popup) => {
642
+ const popupUrl = popup.url();
643
+ logger.info({ url: popupUrl }, "[crawler] OAuth popup detected \u2014 waiting for close");
644
+ popup.waitForEvent("close", { timeout: 12e4 }).then(() => logger.info("[crawler] OAuth popup closed \u2014 SSO flow complete")).catch(() => logger.warn("[crawler] OAuth popup timed out or errored"));
645
+ });
646
+ }
647
+ const pwContext2 = playwrightBrowser?.contexts()?.[0];
648
+ if (pwContext2) {
649
+ const pwPages = pwContext2.pages();
650
+ rawPage = pwPages.find((p) => p.url() !== "about:blank") ?? pwPages[0] ?? rawPage;
651
+ }
652
+ if (playwrightProxy?.username && pwContext2 && rawPage && rawPage !== page) {
653
+ try {
654
+ const proxyCdp = await pwContext2.newCDPSession(rawPage);
655
+ await proxyCdp.send("Fetch.enable", { handleAuthRequests: true, patterns: [{ urlPattern: "*" }] });
656
+ proxyCdp.on("Fetch.requestPaused", (e) => {
657
+ proxyCdp.send("Fetch.continueRequest", { requestId: e.requestId }).catch(() => {
658
+ });
659
+ });
660
+ let proxyAuthed = false;
661
+ proxyCdp.on("Fetch.authRequired", async (e) => {
662
+ const isProxy = e.authChallenge?.source === "Proxy";
663
+ try {
664
+ await proxyCdp.send("Fetch.continueWithAuth", {
665
+ requestId: e.requestId,
666
+ authChallengeResponse: isProxy ? { response: "ProvideCredentials", username: playwrightProxy.username, password: playwrightProxy.password ?? "" } : { response: "Default" }
667
+ });
668
+ } catch {
669
+ }
670
+ if (isProxy && !proxyAuthed) {
671
+ proxyAuthed = true;
672
+ proxyCdp.send("Fetch.disable").catch(() => {
673
+ });
674
+ }
675
+ });
676
+ logger.info({ server: playwrightProxy.server }, "[crawler] proxy auth via CDP Fetch handler enabled");
677
+ } catch (e) {
678
+ logger.warn({ err: e?.message }, "[crawler] CDP proxy-auth setup failed \u2014 407 may not resolve");
679
+ }
680
+ }
681
+ } catch {
682
+ }
683
+ if (!rawPage) rawPage = page;
684
+ try {
685
+ await onProgress?.(8, "Loading app");
686
+ await page.goto(opts.appUrl, { waitUntil: "domcontentloaded", timeout: 3e4 }).catch(async () => {
687
+ await page.goto(opts.appUrl, { waitUntil: "commit", timeout: 15e3 }).catch(() => {
688
+ });
689
+ });
690
+ await acceptCookieConsent(page, { waitMs: 1e3 }).catch(() => {
691
+ });
692
+ if (page.url().startsWith("chrome-error://")) {
693
+ throw new Error(`Cannot reach ${opts.appUrl} \u2014 the server refused the connection (likely IP block or DNS failure). Go to Project Settings \u2192 Connectivity \u2192 Proxy and select "Auto" or enter a residential proxy URL.`);
694
+ }
695
+ const initialTitle = await page.title().catch(() => "");
696
+ const isBotChallenge = /^(just a moment|attention required|access denied|ddos protection|cloudflare|checking your browser|enable javascript)/i.test(initialTitle);
697
+ if (isBotChallenge) {
698
+ throw new Error(`Cannot crawl ${opts.appUrl} \u2014 bot protection challenge detected (title: "${initialTitle}"). The site is blocking automated browsers. Go to Project Settings \u2192 Connectivity \u2192 Proxy and select "Auto" (free proxies) or enter a residential proxy URL to bypass.`);
699
+ }
700
+ await page.waitForLoadState("networkidle", { timeout: 1e4 }).catch(() => {
701
+ });
702
+ await waitForStableDOM(page);
703
+ if (!opts.discoveryMode) {
704
+ try {
705
+ await runAuthDiscoveryPhase(page, opts, new URL(opts.appUrl).origin, onProgress);
706
+ } catch (preAuthErr) {
707
+ logger.warn({ err: preAuthErr }, "[crawler] auth-discovery phase failed \u2014 continuing with authenticated crawl");
708
+ }
709
+ }
710
+ let didLogin = false;
711
+ if (opts.authToken) {
712
+ await onProgress?.(18, "API token configured \u2014 skipping login");
713
+ logger.info("authToken provided \u2014 skipping AI login");
714
+ } else if (effectiveStorageState) {
715
+ logger.info("StorageState found \u2014 restoring session, skipping AI login");
716
+ try {
717
+ const state = JSON.parse(effectiveStorageState);
718
+ if (state.cookies?.length) {
719
+ const browserCtx = playwrightBrowser?.contexts()[0];
720
+ if (browserCtx) {
721
+ await browserCtx.addCookies(state.cookies);
722
+ }
723
+ }
724
+ if (state.origins?.length) {
725
+ await rawPage.addInitScript((origins) => {
726
+ for (const entry of origins ?? []) {
727
+ if (window.location.origin === entry.origin) {
728
+ for (const item of entry.localStorage) {
729
+ try {
730
+ localStorage.setItem(item.name, item.value);
731
+ } catch {
732
+ }
733
+ }
734
+ }
735
+ }
736
+ }, state.origins);
737
+ }
738
+ await onProgress?.(12, "Restoring session");
739
+ await rawPage.reload({ waitUntil: "domcontentloaded" });
740
+ await waitForStableDOM(page);
741
+ if (page.url() === opts.appUrl || page.url() === opts.appUrl.replace(/\/$/, "")) {
742
+ await waitForURLChange(page, opts.appUrl).catch(() => {
743
+ });
744
+ await waitForStableDOM(page);
745
+ }
746
+ const postRestoreUrl = page.url();
747
+ const LOGIN_PATH = /\/(login|signin|sign-in|auth|log-in|sso|oauth|account\/login)(\/|$|\?)/i;
748
+ const ERROR_PATH = /\/(session-expired|expired|unauthorized|forbidden|401|403|error|logout|logged-out)(\/|$|\?)/i;
749
+ const redirectedToLogin = LOGIN_PATH.test(new URL(postRestoreUrl).pathname);
750
+ const redirectedToError = ERROR_PATH.test(new URL(postRestoreUrl).pathname);
751
+ const hasLoginForm = await page.evaluate(() => {
752
+ const inputs = Array.from(document.querySelectorAll("input"));
753
+ const hasEmailOrUser = inputs.some(
754
+ (i) => ["email", "username", "user", "login"].some(
755
+ (k) => (i.type || "").includes(k) || (i.name || "").toLowerCase().includes(k) || (i.id || "").toLowerCase().includes(k) || (i.placeholder || "").toLowerCase().includes(k)
756
+ )
757
+ );
758
+ const hasPasswordField = inputs.some((i) => i.type === "password");
759
+ return hasEmailOrUser && hasPasswordField;
760
+ }).catch(() => false);
761
+ const sessionFailed = redirectedToLogin || redirectedToError || hasLoginForm;
762
+ if (sessionFailed) {
763
+ logger.warn({ url: postRestoreUrl, loginForm: hasLoginForm, errorUrl: redirectedToError }, "StorageState restore failed \u2014 session may be expired");
764
+ await AppBrain.saveCrawlConfig(opts.tenantId, opts.projectId, { storageState: null }).catch(() => {
765
+ });
766
+ await page.goto(opts.appUrl, { waitUntil: "domcontentloaded" }).catch(() => {
767
+ });
768
+ await waitForStableDOM(page);
769
+ const urlAfterNav = page.url();
770
+ const stillAtError = ERROR_PATH.test(new URL(urlAfterNav).pathname) || LOGIN_PATH.test(new URL(urlAfterNav).pathname);
771
+ if (stillAtError) {
772
+ const appOrigin = new URL(opts.appUrl).origin;
773
+ for (const loginPath of ["/login", "/signin", "/sign-in", "/auth/login", "/auth"]) {
774
+ try {
775
+ await page.goto(`${appOrigin}${loginPath}`, { waitUntil: "domcontentloaded" });
776
+ const hasForm = await page.evaluate(() => {
777
+ const inputs = Array.from(document.querySelectorAll("input"));
778
+ return inputs.some((i) => i.type === "password");
779
+ }).catch(() => false);
780
+ if (hasForm) {
781
+ logger.info({ loginPath }, "Found login form at path");
782
+ break;
783
+ }
784
+ } catch {
785
+ }
786
+ }
787
+ await waitForStableDOM(page);
788
+ }
789
+ if (opts.credentials) {
790
+ logger.info("Falling back to AI login with credentials");
791
+ await onProgress?.(15, "Session expired \u2014 logging in with credentials\u2026");
792
+ 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 });
793
+ if (!loggedIn) throw new Error("[SESSION_EXPIRED] Stored session expired and credential login also failed. Check your username / password in Project Settings \u2192 Credentials.");
794
+ didLogin = true;
795
+ } else {
796
+ throw new Error("[SESSION_EXPIRED] Stored session has expired. Please re-capture your session in Project Settings \u2192 Advanced: Session Capture, or add username / password as a login fallback.");
797
+ }
798
+ } else {
799
+ await onProgress?.(18, "Session restored");
800
+ logger.info({ url: postRestoreUrl }, "Session restored");
801
+ }
802
+ } catch (err) {
803
+ const msg = String(err.message ?? err);
804
+ if (msg.includes("[SESSION_EXPIRED]") || msg.includes("expired")) throw err;
805
+ logger.warn({ err }, "StorageState restore failed, falling back to AI login");
806
+ if (opts.credentials) {
807
+ await onProgress?.(15, "Session restore failed \u2014 logging in with credentials\u2026");
808
+ 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 });
809
+ if (!loggedIn) throw new Error("[SESSION_EXPIRED] Session restore failed and credential login also failed. Check your username / password in Project Settings \u2192 Credentials.");
810
+ didLogin = true;
811
+ } else {
812
+ throw new Error("[SESSION_EXPIRED] Session restore failed and no credentials were provided. Please re-capture your session in Project Settings \u2192 Advanced: Session Capture.");
813
+ }
814
+ }
815
+ } else if (opts.ssoProvider && opts.ssoDomain && opts.ssoClientId && opts.credentials) {
816
+ await onProgress?.(12, "SSO: fetching token from identity provider");
817
+ try {
818
+ const ssoToken = await getSsoToken(
819
+ {
820
+ provider: opts.ssoProvider,
821
+ domain: opts.ssoDomain,
822
+ clientId: opts.ssoClientId,
823
+ clientSecret: opts.ssoClientSecret,
824
+ scope: opts.ssoScope
825
+ },
826
+ opts.credentials.username,
827
+ opts.credentials.password
828
+ );
829
+ await page.setExtraHTTPHeaders({ "Authorization": `Bearer ${ssoToken}` });
830
+ await page.addInitScript((t) => {
831
+ localStorage.setItem("access_token", t);
832
+ localStorage.setItem("auth_token", t);
833
+ localStorage.setItem("token", t);
834
+ }, ssoToken);
835
+ await page.reload({ waitUntil: "domcontentloaded" });
836
+ didLogin = true;
837
+ await onProgress?.(18, "SSO token injected");
838
+ logger.info({ provider: opts.ssoProvider }, "[crawler] SSO token obtained and injected");
839
+ } catch (ssoErr) {
840
+ logger.warn({ err: ssoErr }, "[crawler] SSO token retrieval failed \u2014 falling back to browser login");
841
+ await onProgress?.(12, "Logging in");
842
+ didLogin = true;
843
+ 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 });
844
+ if (!loggedIn) {
845
+ logger.warn("Stagehand login failed \u2014 trying Skyvern vision fallback");
846
+ await onProgress?.(14, "Trying vision fallback");
847
+ const skyvernResult = await skyvernFallback({
848
+ taskDescription: `Log in to ${opts.appUrl} using username "${opts.credentials.username}" and navigate to the main dashboard.`,
849
+ url: opts.appUrl,
850
+ credentials: opts.credentials
851
+ }, opts.skyvernConfig);
852
+ if (skyvernResult === "failed") {
853
+ throw new Error(
854
+ "Login failed on both Stagehand (DOM) and Skyvern (vision). Check credentials. For CAPTCHA/SSO sites, set SKYVERN_API_URL + SKYVERN_API_KEY."
855
+ );
856
+ }
857
+ }
858
+ await onProgress?.(18, "Logged in");
859
+ }
860
+ } else if (opts.credentials) {
861
+ await onProgress?.(12, "Logging in");
862
+ didLogin = true;
863
+ 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 });
864
+ if (!loggedIn) {
865
+ logger.warn("Stagehand login failed \u2014 trying Skyvern vision fallback");
866
+ await onProgress?.(14, "Trying vision fallback");
867
+ const skyvernResult = await skyvernFallback({
868
+ taskDescription: `Log in to ${opts.appUrl} using username "${opts.credentials.username}" and navigate to the main dashboard.`,
869
+ url: opts.appUrl,
870
+ credentials: opts.credentials
871
+ }, opts.skyvernConfig);
872
+ if (skyvernResult === "failed") {
873
+ throw new Error(
874
+ "Login failed on both Stagehand (DOM) and Skyvern (vision). Check credentials. For CAPTCHA/SSO sites, set SKYVERN_API_URL + SKYVERN_API_KEY."
875
+ );
876
+ }
877
+ }
878
+ await onProgress?.(18, "Logged in");
879
+ } else {
880
+ await onProgress?.(18, "No auth needed");
881
+ }
882
+ if (didLogin) {
883
+ try {
884
+ const freshState = await playwrightBrowser.contexts()[0]?.storageState();
885
+ if (freshState) {
886
+ await AppBrain.saveCrawlConfig(opts.tenantId, opts.projectId, { storageState: JSON.stringify(freshState) });
887
+ logger.info("Fresh session state captured and saved after credential login");
888
+ await onProgress?.(19, "Session saved \u2014 will reuse on next crawl");
889
+ }
890
+ } catch (e) {
891
+ logger.warn({ err: e }, "Could not save refreshed session state \u2014 crawl will continue");
892
+ }
893
+ await waitForURLChange(page, opts.appUrl);
894
+ }
895
+ const origin = new URL(page.url()).origin;
896
+ const maxScreens = opts.maxScreens ?? Infinity;
897
+ const segmentSize = opts.segmentSize ?? 9999999;
898
+ const selective = !!(opts.selectiveUrls && opts.selectiveUrls.length > 0);
899
+ if (!selective) {
900
+ await AppBrain.prepareForRecrawl(opts.tenantId, opts.projectId, opts.jobId);
901
+ await AppBrain.clearFlows(opts.tenantId, opts.projectId);
902
+ }
903
+ const canonicalize = (raw) => {
904
+ try {
905
+ const u = new URL(raw);
906
+ u.hostname = u.hostname.replace(/^www\./, "");
907
+ [
908
+ "utm_source",
909
+ "utm_medium",
910
+ "utm_campaign",
911
+ "utm_term",
912
+ "utm_content",
913
+ "ref",
914
+ "source",
915
+ "_gl",
916
+ "fbclid",
917
+ "gclid",
918
+ "ts",
919
+ "timestamp",
920
+ "cache",
921
+ "cacheBust",
922
+ "cache_bust",
923
+ "_",
924
+ "nonce",
925
+ "session",
926
+ "sid",
927
+ // WooCommerce / e-commerce action params
928
+ "add-to-cart",
929
+ "add_to_wishlist",
930
+ "quantity",
931
+ "wc-ajax",
932
+ "orderby",
933
+ "min_price",
934
+ "max_price",
935
+ "removed_item",
936
+ "restore",
937
+ "remove_item",
938
+ "undo_item",
939
+ "coupon_code",
940
+ "apply_coupon",
941
+ "added-to-cart",
942
+ "wc_error",
943
+ "wc_notice",
944
+ "wc_notice_type",
945
+ "update_cart",
946
+ "proceed",
947
+ "_wpnonce",
948
+ "post_type",
949
+ "product_cat",
950
+ // WooCommerce facet/layered-nav filter params — each combination generates a unique URL
951
+ // but all render the same template; not stripping these causes infinite URL explosion
952
+ // (N filter options × M values = exponential queue growth)
953
+ "filter_color",
954
+ "filter_colour",
955
+ "filter_size",
956
+ "filter_brand",
957
+ "filter_category",
958
+ "filter_rating",
959
+ "filter_stock_status",
960
+ "filter_pa_color",
961
+ "filter_pa_size",
962
+ "filter_pa_brand",
963
+ "filter_pa_material",
964
+ "filter_pa_weight",
965
+ "filter_pa_style",
966
+ "filter_pa_gender",
967
+ "filter_pa_age",
968
+ "filter_pa_type",
969
+ "filter_pa_pattern",
970
+ "query_type_color",
971
+ "query_type_size",
972
+ "query_type_brand",
973
+ "query_type_pa_color",
974
+ "query_type_pa_size",
975
+ "query_type_pa_brand",
976
+ "in_stock",
977
+ "on_sale",
978
+ // Shopify facet filters
979
+ "sort_by",
980
+ "constraint",
981
+ "filter.p.tag",
982
+ "filter.v.price.gte",
983
+ "filter.v.price.lte",
984
+ "filter.p.m.global.color",
985
+ "filter.p.m.global.size"
986
+ ].forEach((p) => u.searchParams.delete(p));
987
+ for (const key of Array.from(u.searchParams.keys())) {
988
+ if (/^filter[._]|^query_type_/i.test(key)) u.searchParams.delete(key);
989
+ }
990
+ u.searchParams.sort();
991
+ u.pathname = u.pathname.replace(/\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}(?=\/|$)/gi, "/{id}").replace(/\/c[a-z0-9]{20,}(?=\/|$)/g, "/{id}").replace(/\/\d{4,}(?=\/|$)/g, "/{id}").replace(/\/\d+-\d+(?=\/|$)/g, "/{id}").replace(/\/+$/, "") || "/";
992
+ if (!/^#!?\//.test(u.hash)) u.hash = "";
993
+ return u.toString();
994
+ } catch {
995
+ return raw;
996
+ }
997
+ };
998
+ const gitInfo = getGitInfo();
999
+ const visited = /* @__PURE__ */ new Map();
1000
+ if (opts.skipUrls?.length) {
1001
+ for (const u of opts.skipUrls) visited.set(canonicalize(u), "skipped");
1002
+ }
1003
+ let checkpointSuccessCount = 0;
1004
+ let maxSeenTotal = 0;
1005
+ const startUrl = page.url();
1006
+ const resumeUrls = opts.resumeUrls ?? [];
1007
+ const gqlIntrospectionPromise = (async () => {
1008
+ const gqlEndpoints = guessGraphQLEndpoints(opts.appUrl);
1009
+ for (const ep of gqlEndpoints) {
1010
+ const result = await introspectGraphQL(ep, { timeoutMs: 5e3 }).catch(() => null);
1011
+ if (result?.schema) {
1012
+ logger.info({ endpoint: ep, typeCount: result.schema.types.length }, "[crawler] GraphQL schema discovered");
1013
+ return result;
1014
+ }
1015
+ }
1016
+ return null;
1017
+ })();
1018
+ const sitemapUrls = opts.useSitemap && !selective && resumeUrls.length === 0 ? await discoverSitemapUrls(opts.appUrl, opts.sitemapUrl) : [];
1019
+ const filteredResumeUrls = resumeUrls.filter((u) => {
1020
+ try {
1021
+ return !isGeneratedDynamicPath(new URL(u).pathname);
1022
+ } catch {
1023
+ return true;
1024
+ }
1025
+ });
1026
+ const seedUrls = selective ? opts.selectiveUrls : filteredResumeUrls.length > 0 ? filteredResumeUrls : [
1027
+ startUrl,
1028
+ // User-configured seed URLs added to queue; link discovery still runs from each.
1029
+ ...(opts.startUrls ?? []).filter((u) => canonicalize(u) !== canonicalize(startUrl)),
1030
+ ...sitemapUrls.filter((u) => canonicalize(u) !== canonicalize(startUrl))
1031
+ ];
1032
+ const queued = new Set(seedUrls.map(canonicalize));
1033
+ const queue = new CrawlFrontier(opts.traversalStrategy ?? "BFS", seedUrls, opts.bestFirstKeywords, opts.urlFilter);
1034
+ const deduplicator = new ContentDeduplicator(0.88);
1035
+ const deadLinkTracker = new DeadLinkTracker();
1036
+ const redirectRegistry = new RedirectRegistry();
1037
+ if (!selective && resumeUrls.length === 0) {
1038
+ try {
1039
+ const priorMap = projectForProfile ?? await AppBrain.getProjectMap(opts.tenantId, opts.projectId);
1040
+ const orphanReport = await AppBrain.getOrphanScreens(opts.tenantId, opts.projectId).catch(() => null);
1041
+ const orphanIdSet = new Set(
1042
+ (orphanReport?.screens ?? []).filter((s) => s.classification !== "REACHABLE").map((s) => s.id)
1043
+ );
1044
+ const priorScreens = (priorMap?.screens ?? []).filter((s) => s.status !== "PENDING_REMOVAL").filter((s) => {
1045
+ const u = s.url;
1046
+ if (!u) return false;
1047
+ try {
1048
+ const { origin: origin2, pathname } = new URL(u);
1049
+ if (origin2 !== new URL(startUrl).origin) return false;
1050
+ if (isGeneratedDynamicPath(pathname)) return false;
1051
+ return true;
1052
+ } catch {
1053
+ return false;
1054
+ }
1055
+ }).filter((s) => !queued.has(canonicalize(s.url)));
1056
+ let orphanSeedCount = 0;
1057
+ for (const s of priorScreens) {
1058
+ const u = s.url;
1059
+ queued.add(canonicalize(u));
1060
+ const isOrphan = orphanIdSet.has(s.id);
1061
+ queue.push(u, isOrphan ? { parentElements: 0 } : void 0);
1062
+ if (isOrphan) orphanSeedCount++;
1063
+ }
1064
+ if (priorScreens.length > 0) {
1065
+ logger.info(
1066
+ { count: priorScreens.length, orphanBoosted: orphanSeedCount },
1067
+ "[crawler] re-seeded prior screen URLs from DB"
1068
+ );
1069
+ }
1070
+ } catch (err) {
1071
+ logger.warn({ err: String(err) }, "[crawler] non-fatal: could not load prior screen URLs for re-seeding");
1072
+ }
1073
+ }
1074
+ if (opts.useSitemap !== false) {
1075
+ const g11SitemapUrls = [];
1076
+ const sitemapCandidates = [
1077
+ `${opts.appUrl.replace(/\/$/, "")}/sitemap.xml`,
1078
+ `${opts.appUrl.replace(/\/$/, "")}/sitemap_index.xml`,
1079
+ `${opts.appUrl.replace(/\/$/, "")}/robots.txt`
1080
+ ];
1081
+ const LOGIN_REDIRECT_PATH = /\/(login|signin|sign-in|auth|log-in)(\/|$|\?)/i;
1082
+ try {
1083
+ for (const candidate of sitemapCandidates) {
1084
+ try {
1085
+ const resp = await fetch(candidate, { signal: AbortSignal.timeout(8e3) }).catch(() => null);
1086
+ if (!resp?.ok) continue;
1087
+ const text = await resp.text();
1088
+ if (candidate.endsWith("robots.txt")) {
1089
+ const sitemapLines = text.split("\n").filter((l) => l.toLowerCase().startsWith("sitemap:"));
1090
+ for (const line of sitemapLines) {
1091
+ const u = line.split(":").slice(1).join(":").trim();
1092
+ if (u) sitemapCandidates.push(u);
1093
+ }
1094
+ continue;
1095
+ }
1096
+ const locs = text.match(/<loc>([^<]+)<\/loc>/gi) ?? [];
1097
+ for (const loc of locs) {
1098
+ const u = loc.replace(/<\/?loc>/gi, "").trim();
1099
+ if (u.startsWith("http")) g11SitemapUrls.push(u);
1100
+ }
1101
+ if (g11SitemapUrls.length > 0) break;
1102
+ } catch {
1103
+ }
1104
+ }
1105
+ if (g11SitemapUrls.length > 0) {
1106
+ logger.info({ count: g11SitemapUrls.length }, "[crawler] G11: sitemap discovered public URL candidates");
1107
+ const sitemapOrigin = new URL(opts.appUrl).origin;
1108
+ let sitemapCaptured = 0;
1109
+ for (const sUrl of g11SitemapUrls.slice(0, 30)) {
1110
+ try {
1111
+ const u = new URL(sUrl);
1112
+ if (u.origin !== sitemapOrigin) continue;
1113
+ await page.goto(sUrl, { waitUntil: "domcontentloaded", timeout: 12e3 });
1114
+ await page.waitForLoadState("networkidle", { timeout: 5e3 }).catch(() => {
1115
+ });
1116
+ await waitForStableDOM(page);
1117
+ const landedUrl = page.url();
1118
+ if (LOGIN_REDIRECT_PATH.test(new URL(landedUrl).pathname)) {
1119
+ const canon = canonicalize(sUrl);
1120
+ if (!queued.has(canon)) {
1121
+ queued.add(canon);
1122
+ queue.push(sUrl);
1123
+ }
1124
+ logger.info({ url: sUrl }, "[crawler] G11: sitemap URL requires auth \u2014 added to BFS queue");
1125
+ continue;
1126
+ }
1127
+ const pagePath = u.pathname.replace(/^\//, "") || "home";
1128
+ const pageName = pagePath.split("/").map((s) => s.charAt(0).toUpperCase() + s.slice(1)).join(" ") || "Home";
1129
+ const shotPath = path.join(opts.screenshotDir ?? "/tmp", `sitemap-preauth-${sitemapCaptured}.png`);
1130
+ await waitForScreenshotReady(page);
1131
+ await page.screenshot({ path: shotPath, fullPage: true }).catch(() => {
1132
+ });
1133
+ const pubScreen = await AppBrain.saveScreen(opts.tenantId, opts.projectId, pageName, sUrl, opts.jobId, "SITEMAP").catch(() => null);
1134
+ if (pubScreen) {
1135
+ await AppBrain.saveScreenshot(opts.tenantId, opts.projectId, pubScreen.id, shotPath, pubScreen.version, "DESKTOP").catch(() => {
1136
+ });
1137
+ logger.info({ url: sUrl, name: pageName }, "[crawler] G11: captured public sitemap page");
1138
+ }
1139
+ sitemapCaptured++;
1140
+ } catch (sitemapPageErr) {
1141
+ logger.warn({ url: sUrl, err: String(sitemapPageErr) }, "[crawler] G11: sitemap page failed \u2014 skipping");
1142
+ }
1143
+ }
1144
+ logger.info({ captured: sitemapCaptured }, "[crawler] G11: pre-auth sitemap capture complete");
1145
+ await page.goto(opts.appUrl, { waitUntil: "domcontentloaded", timeout: 15e3 }).catch(() => {
1146
+ });
1147
+ await waitForStableDOM(page);
1148
+ }
1149
+ } catch (sitemapErr) {
1150
+ logger.warn({ err: String(sitemapErr) }, "[crawler] G11: pre-auth sitemap discovery failed \u2014 continuing");
1151
+ }
1152
+ }
1153
+ const canonicalEntryUrl = canonicalize(startUrl);
1154
+ const canonicalSitemapUrls = new Set(sitemapUrls.map(canonicalize));
1155
+ const canonicalSeedUrls = new Set(seedUrls.map(canonicalize));
1156
+ const discoveryMethodFor = (url) => {
1157
+ const c = canonicalize(url);
1158
+ if (c === canonicalEntryUrl) return "ENTRY_POINT";
1159
+ if (canonicalSitemapUrls.has(c)) return "SITEMAP";
1160
+ if (canonicalSeedUrls.has(c)) return "SEED_URL";
1161
+ return "LINK_FOLLOW";
1162
+ };
1163
+ const edges = [];
1164
+ const pendingTransitions = [];
1165
+ const discovered = [];
1166
+ const performanceMetrics = [];
1167
+ const seenDomHashes = new Set(opts.knownDomHashes ?? []);
1168
+ 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;
1169
+ const segIdx = opts.segmentIndex ?? 1;
1170
+ const robotsCache = opts.respectRobotsTxt ? new RobotsCache() : null;
1171
+ let avgNavMs = 0;
1172
+ let navCount = 0;
1173
+ let aiErrorStreak = 0;
1174
+ let aiSkipCount = 0;
1175
+ let aiRateLimitWarned = false;
1176
+ let totalAttemptsThisSegment = 0;
1177
+ const maxAttempts = Math.max(segmentSize * 4, 200);
1178
+ while (queue.length > 0 && visited.size < maxScreens && visited.size < segmentSize) {
1179
+ if (++totalAttemptsThisSegment > maxAttempts) {
1180
+ logger.warn({ maxAttempts, visited: visited.size, queued: queue.length }, "[crawler] attempt cap reached \u2014 stopping to prevent URL explosion");
1181
+ break;
1182
+ }
1183
+ if (opts.abortSignal?.aborted) {
1184
+ logger.info("Abort signal received \u2014 stopping crawl");
1185
+ throw new Error("CRAWL_ABORTED");
1186
+ }
1187
+ if (opts.autoThrottle && navCount > 0) {
1188
+ const delayMs = Math.min(5e3, Math.max(0, avgNavMs * 0.5));
1189
+ if (delayMs > 100) await page.waitForTimeout(delayMs).catch(() => {
1190
+ });
1191
+ }
1192
+ const url = queue.shift();
1193
+ const canonUrl = canonicalize(url);
1194
+ logger.info({ url, visited: visited.size, queued: queue.length, segment: `${visited.size + 1}/${segmentSize}` }, "[crawler] visiting URL");
1195
+ const apiObservationStartIndex = capturedApiEndpoints.length;
1196
+ if (visited.has(canonUrl)) continue;
1197
+ if (robotsCache && !await robotsCache.isAllowed(url)) {
1198
+ logger.info({ url }, "[crawler] robots.txt: skipping disallowed URL");
1199
+ continue;
1200
+ }
1201
+ try {
1202
+ const { pathname } = new URL(url);
1203
+ if (SKIP_PATH.test(pathname)) {
1204
+ console.log(`[crawler] skip content page: ${url}`);
1205
+ continue;
1206
+ }
1207
+ if (isGeneratedDynamicPath(pathname)) {
1208
+ console.log(`[crawler] skip generated dynamic route: ${url}`);
1209
+ continue;
1210
+ }
1211
+ } catch {
1212
+ }
1213
+ try {
1214
+ let securityHeaders = null;
1215
+ let httpStatus = null;
1216
+ const urlBase = url.replace(/[?#].*/, "");
1217
+ const cdpSession = page.mainSession ?? page._session ?? null;
1218
+ const secHeadersCdp = (evt) => {
1219
+ if (securityHeaders) return;
1220
+ try {
1221
+ const responseUrl = evt?.response?.url ?? "";
1222
+ if (!responseUrl.startsWith(urlBase)) return;
1223
+ httpStatus = evt?.response?.status ?? null;
1224
+ const headers = {};
1225
+ for (const h of evt?.response?.headers ?? []) headers[h.name.toLowerCase()] = h.value;
1226
+ securityHeaders = {
1227
+ csp: headers["content-security-policy"] ?? headers["content-security-policy-report-only"] ?? null,
1228
+ hsts: headers["strict-transport-security"] ?? null,
1229
+ xContentType: headers["x-content-type-options"] ?? null,
1230
+ xFrame: headers["x-frame-options"] ?? null,
1231
+ referrer: headers["referrer-policy"] ?? null
1232
+ };
1233
+ } catch {
1234
+ }
1235
+ };
1236
+ if (cdpSession?.on) cdpSession.on("Network.responseReceived", secHeadersCdp);
1237
+ const wsDetector = attachWebSocketDetector(cdpSession);
1238
+ if (url !== page.url()) {
1239
+ const navStart = Date.now();
1240
+ const redirectHops = [];
1241
+ const onRedirectResponse = (res) => {
1242
+ const st = res.status();
1243
+ if (st >= 300 && st < 400) {
1244
+ redirectHops.push({ from: res.url(), to: res.headers()["location"] ?? "", statusCode: st });
1245
+ } else if (st >= 400) {
1246
+ deadLinkTracker.record(res.url(), st, { sourceUrl: url });
1247
+ }
1248
+ };
1249
+ rawPage.on("response", onRedirectResponse);
1250
+ await page.goto(url, { waitUntil: "domcontentloaded", timeout: 3e4 }).catch((navErr) => {
1251
+ logger.warn({ url, err: navErr?.message ?? String(navErr) }, "[crawler] navigation failed \u2014 skipping");
1252
+ });
1253
+ rawPage.off("response", onRedirectResponse);
1254
+ if (redirectHops.length > 0) {
1255
+ redirectRegistry.register({
1256
+ originalUrl: url,
1257
+ finalUrl: page.url(),
1258
+ hops: redirectHops.map((h, i) => ({ ...h, durationMs: i === 0 ? Date.now() - navStart : 0 })),
1259
+ isCircular: new Set(redirectHops.map((h) => h.from)).size < redirectHops.length,
1260
+ hopCount: redirectHops.length,
1261
+ totalDurationMs: Date.now() - navStart
1262
+ });
1263
+ }
1264
+ const navMs = Date.now() - navStart;
1265
+ navCount++;
1266
+ avgNavMs = avgNavMs + (navMs - avgNavMs) / navCount;
1267
+ }
1268
+ if (page.url().startsWith("chrome-error://")) {
1269
+ console.warn(`[crawler] connection failed for ${url} \u2014 skipping (chrome-error page)`);
1270
+ continue;
1271
+ }
1272
+ await page.waitForLoadState("networkidle", { timeout: 8e3 }).catch(() => {
1273
+ });
1274
+ await waitForStableDOM(page);
1275
+ if (!opts.discoveryMode) {
1276
+ try {
1277
+ const appModalVisible = await page.evaluate(() => {
1278
+ const dialogs = Array.from(document.querySelectorAll('[role="dialog"], [role="alertdialog"], [aria-modal="true"]'));
1279
+ return dialogs.some((el) => {
1280
+ const text = (el.textContent ?? "").toLowerCase();
1281
+ const isCookieBanner = /(cookie|consent|gdpr|privacy policy|accept all|we use cookies)/i.test(text);
1282
+ const isVisible = el.offsetParent !== null || el.style.display !== "none";
1283
+ return isVisible && !isCookieBanner;
1284
+ });
1285
+ }).catch(() => false);
1286
+ if (appModalVisible) {
1287
+ const modalHash = url.replace(/[^a-z0-9]/gi, "").slice(-12);
1288
+ const modalShotPath = path.join(opts.screenshotDir ?? "/tmp", `modal-${modalHash}.png`);
1289
+ await page.screenshot({ path: modalShotPath, fullPage: false }).catch(() => {
1290
+ });
1291
+ const modalName = await page.evaluate(() => {
1292
+ const h = document.querySelector('[role="dialog"] h1,[role="dialog"] h2,[role="dialog"] [role="heading"]');
1293
+ return h?.textContent?.trim() ?? "Modal";
1294
+ }).catch(() => "Modal");
1295
+ const modalScreen = await AppBrain.saveScreen(opts.tenantId, opts.projectId, modalName, url, opts.jobId, discoveryMethodFor(url)).catch(() => null);
1296
+ if (modalScreen) {
1297
+ await AppBrain.saveScreenshot(opts.tenantId, opts.projectId, modalScreen.id, modalShotPath, modalScreen.version, "DESKTOP").catch(() => {
1298
+ });
1299
+ logger.info({ url, name: modalName }, "[crawler] captured app modal before dismiss");
1300
+ }
1301
+ }
1302
+ } catch {
1303
+ }
1304
+ await dismissOverlays(page);
1305
+ await infiniteScrollToBottom(page, { maxScrolls: 30, scrollDelay: 350, stabilizeMs: 500 }).catch(() => {
1306
+ });
1307
+ }
1308
+ if (cdpSession?.off) cdpSession.off("Network.responseReceived", secHeadersCdp);
1309
+ wsDetector.detach();
1310
+ if (httpStatus === 429) {
1311
+ const backoffMs = Math.min(6e4, 1e3 * Math.pow(2, Math.min(navCount, 6)));
1312
+ logger.warn({ url, httpStatus, backoffMs }, "[crawler] 429 rate-limited \u2014 backing off");
1313
+ await new Promise((r) => setTimeout(r, backoffMs + Math.random() * 1e3));
1314
+ }
1315
+ if (httpStatus !== null && httpStatus >= 400) {
1316
+ logger.info({ url, httpStatus }, "[crawler] skip \u2014 server returned HTTP error");
1317
+ continue;
1318
+ }
1319
+ const BFS_ERROR_PATH = /\/(session-expired|expired|unauthorized|forbidden|401|403|logout|logged-out)(\/|$|\?)/i;
1320
+ const BFS_LOGIN_PATH = /\/(login|signin|sign-in|auth|log-in|sso|account\/login)(\/|$|\?)/i;
1321
+ const landedUrl = page.url();
1322
+ const weWantedThisPage = BFS_ERROR_PATH.test(new URL(url).pathname) || BFS_LOGIN_PATH.test(new URL(url).pathname);
1323
+ const redirectedToError = !weWantedThisPage && (BFS_ERROR_PATH.test(new URL(landedUrl).pathname) || BFS_LOGIN_PATH.test(new URL(landedUrl).pathname));
1324
+ if (redirectedToError) {
1325
+ logger.warn({ requested: url, landed: landedUrl }, "[crawler] Mid-crawl redirect to auth/error \u2014 attempting re-auth");
1326
+ let reauthed = false;
1327
+ if (opts.credentials) {
1328
+ reauthed = await loginWithStagehand(
1329
+ stagehand,
1330
+ opts.credentials,
1331
+ opts.appUrl,
1332
+ { apiKey: opts.captchaSolverApiKey, provider: opts.captchaSolverProvider },
1333
+ { apiKey: opts.mailslurpApiKey, inboxId: opts.mailslurpInboxId },
1334
+ void 0,
1335
+ { tenantId: opts.tenantId, projectId: opts.projectId, jobId: opts.jobId }
1336
+ ).catch(() => false);
1337
+ if (reauthed) {
1338
+ logger.info({ url }, "[crawler] Re-auth succeeded \u2014 retrying URL");
1339
+ await page.goto(url, { waitUntil: "domcontentloaded" }).catch(() => {
1340
+ });
1341
+ await page.waitForLoadState("networkidle", { timeout: 8e3 }).catch(() => {
1342
+ });
1343
+ await waitForStableDOM(page);
1344
+ const afterReauthUrl = page.url();
1345
+ if (BFS_ERROR_PATH.test(new URL(afterReauthUrl).pathname) || BFS_LOGIN_PATH.test(new URL(afterReauthUrl).pathname)) {
1346
+ logger.warn({ url }, "[crawler] Still redirecting after re-auth \u2014 skipping this URL");
1347
+ continue;
1348
+ }
1349
+ } else {
1350
+ logger.warn({ url }, "[crawler] Re-auth failed \u2014 stopping BFS (session unrecoverable)");
1351
+ AppBrain.saveCrawlEvidence(opts.tenantId, opts.projectId, {
1352
+ jobId: opts.jobId,
1353
+ type: "AUTH_FAILURE",
1354
+ payload: { url, landed: landedUrl, reason: "Re-authentication failed. Session is unrecoverable. Re-capture the session in project settings and retry the crawl." }
1355
+ }).catch(() => {
1356
+ });
1357
+ break;
1358
+ }
1359
+ } else {
1360
+ logger.warn("[crawler] Session expired mid-crawl, no credentials to recover \u2014 stopping BFS");
1361
+ AppBrain.saveCrawlEvidence(opts.tenantId, opts.projectId, {
1362
+ jobId: opts.jobId,
1363
+ type: "AUTH_FAILURE",
1364
+ payload: { url, landed: landedUrl, reason: "Session expired and no credentials are configured. Add credentials in project settings and re-capture the session." }
1365
+ }).catch(() => {
1366
+ });
1367
+ break;
1368
+ }
1369
+ }
1370
+ if (!redirectedToError && landedUrl !== url) {
1371
+ logger.info({ requested: url, landed: landedUrl }, "[crawler] navigated with redirect");
1372
+ }
1373
+ if (opts.discoveryMode) {
1374
+ visited.set(canonicalize(url), url);
1375
+ await (rawPage ?? page).waitForLoadState("networkidle", { timeout: 1e4 }).catch(() => {
1376
+ });
1377
+ await page.waitForTimeout(500).catch(() => {
1378
+ });
1379
+ const rawLinksSet = /* @__PURE__ */ new Set();
1380
+ const addLink = (h) => {
1381
+ if (!h || h === "#" || h.startsWith("javascript:") || h.startsWith("mailto:") || h.startsWith("tel:")) return;
1382
+ try {
1383
+ const href = new URL(h, origin).toString();
1384
+ if (isUsefulNavigationUrl(href, origin)) rawLinksSet.add(href);
1385
+ } catch {
1386
+ }
1387
+ };
1388
+ const pwPage = rawPage ?? page;
1389
+ await pwPage.locator("a[href]").evaluateAll(
1390
+ (anchors) => anchors.map((a) => a.href || a.getAttribute("href") || "")
1391
+ ).then((hrefs) => hrefs.forEach(addLink)).catch(() => {
1392
+ });
1393
+ await pwPage.locator("[data-href],[data-url],[data-link]").evaluateAll(
1394
+ (els) => els.map((el) => el.dataset?.href || el.dataset?.url || el.dataset?.link || "")
1395
+ ).then((hrefs) => hrefs.forEach(addLink)).catch(() => {
1396
+ });
1397
+ await pwPage.locator("[href]:not(a):not(link):not(base):not(script):not(style)").evaluateAll(
1398
+ (els) => els.map((el) => el.getAttribute("href") || "")
1399
+ ).then((hrefs) => hrefs.forEach(addLink)).catch(() => {
1400
+ });
1401
+ const playwrightCount = rawLinksSet.size;
1402
+ console.log(`[discover] playwright-locator found ${playwrightCount} links for ${url}`);
1403
+ const evalPage = rawPage ?? page;
1404
+ await evalPage.evaluate((o) => {
1405
+ try {
1406
+ const hrefs = [];
1407
+ const add = (h) => {
1408
+ if (h && h !== "#" && !h.startsWith("javascript:") && !h.startsWith("mailto:") && !h.startsWith("tel:")) {
1409
+ if (h.startsWith(o) || h.startsWith("/")) hrefs.push(h);
1410
+ }
1411
+ };
1412
+ const queue2 = [document];
1413
+ const seenRoots = /* @__PURE__ */ new WeakSet();
1414
+ seenRoots.add(document);
1415
+ let shadowRootsFound = 0;
1416
+ while (queue2.length > 0) {
1417
+ const root = queue2.shift();
1418
+ if (!root) continue;
1419
+ try {
1420
+ root.querySelectorAll("a[href]").forEach((a) => add(a.href || a.getAttribute("href") || ""));
1421
+ root.querySelectorAll("[data-href],[data-url],[data-link]").forEach((el) => {
1422
+ add(el.dataset && (el.dataset.href || el.dataset.url || el.dataset.link) || "");
1423
+ });
1424
+ root.querySelectorAll("[href]:not(a):not(link):not(base):not(script):not(style)").forEach((el) => {
1425
+ const h = el.getAttribute("href") || "";
1426
+ if (h && !h.startsWith("#")) add(h);
1427
+ });
1428
+ root.querySelectorAll("*").forEach((el) => {
1429
+ try {
1430
+ if (el.shadowRoot && !seenRoots.has(el.shadowRoot)) {
1431
+ seenRoots.add(el.shadowRoot);
1432
+ queue2.push(el.shadowRoot);
1433
+ shadowRootsFound++;
1434
+ }
1435
+ } catch (_) {
1436
+ }
1437
+ });
1438
+ } catch (_) {
1439
+ }
1440
+ }
1441
+ return { hrefs, shadowRootsFound };
1442
+ } catch {
1443
+ return { hrefs: [], shadowRootsFound: 0 };
1444
+ }
1445
+ }, origin).then((result) => {
1446
+ (result?.hrefs ?? []).forEach(addLink);
1447
+ if (result?.shadowRootsFound > 0) {
1448
+ console.log(`[discover] bfs found ${result.shadowRootsFound} shadow roots, added ${rawLinksSet.size - playwrightCount} extra links for ${url}`);
1449
+ }
1450
+ }).catch(() => {
1451
+ });
1452
+ await evalPage.evaluate(() => {
1453
+ const routes = [];
1454
+ try {
1455
+ const nd = window.__NEXT_DATA__;
1456
+ if (nd?.page) routes.push(nd.page);
1457
+ } catch (_) {
1458
+ }
1459
+ try {
1460
+ const w = window;
1461
+ const rr = w.__reactRouterRoutes || w.__reactRouterManifest?.routes;
1462
+ if (rr) {
1463
+ const collect = (r) => {
1464
+ if (r?.path && r.path !== "*") routes.push(r.path.startsWith("/") ? r.path : "/" + r.path);
1465
+ if (r?.children) r.children.forEach(collect);
1466
+ };
1467
+ (Array.isArray(rr) ? rr : Object.values(rr)).forEach(collect);
1468
+ }
1469
+ } catch (_) {
1470
+ }
1471
+ return routes;
1472
+ }).then((routes) => {
1473
+ (routes ?? []).forEach((p) => {
1474
+ try {
1475
+ addLink(new URL(p, origin).href);
1476
+ } catch {
1477
+ }
1478
+ });
1479
+ }).catch(() => {
1480
+ });
1481
+ const rawLinks = Array.from(rawLinksSet);
1482
+ console.log(`[discover] ${url} \u2192 rawLinks=${rawLinks.length} origin=${origin} pageUrl=${page.url()}`);
1483
+ if (rawLinks.length > 0) console.log(`[discover] sample links: ${rawLinks.slice(0, 5).join(" | ")}`);
1484
+ let newlyQueued = 0;
1485
+ for (const link of rawLinks) {
1486
+ try {
1487
+ const resolved = link.startsWith("/") ? `${origin}${link}` : link;
1488
+ const u = new URL(resolved);
1489
+ if (!/^#!?\//.test(u.hash)) u.hash = "";
1490
+ const clean = u.toString();
1491
+ if (!isUsefulNavigationUrl(clean, origin)) continue;
1492
+ const norm = canonicalize(clean);
1493
+ if (!visited.has(norm) && !queued.has(norm)) {
1494
+ queued.add(norm);
1495
+ queue.push(clean);
1496
+ newlyQueued++;
1497
+ }
1498
+ } catch {
1499
+ }
1500
+ }
1501
+ if (newlyQueued > 0) console.log(`[discover] queued ${newlyQueued} new URLs, queue size=${queue.length}`);
1502
+ const pct = 10 + Math.round(visited.size / Math.max(1, opts.maxScreens ?? 1e3) * 80);
1503
+ await onProgress?.(Math.min(pct, 89), `Discovered ${visited.size} URLs\u2026`);
1504
+ if (opts.onUrlsDiscovered) {
1505
+ await opts.onUrlsDiscovered(Array.from(visited.values())).catch(() => {
1506
+ });
1507
+ }
1508
+ continue;
1509
+ }
1510
+ const preDomHash = await computeDomHash(page);
1511
+ if (seenDomHashes.has(preDomHash)) {
1512
+ console.log(`[crawler] skip ${url} \u2014 duplicate DOM structure (same content as prior page)`);
1513
+ continue;
1514
+ }
1515
+ const pageTitle = await page.title().catch(() => "");
1516
+ if (/\b(not found|404|page not found|this page could not be found)\b/i.test(pageTitle)) {
1517
+ console.log(`[crawler] skip ${url} \u2014 error page detected (title: "${pageTitle}")`);
1518
+ seenDomHashes.add(preDomHash);
1519
+ continue;
1520
+ }
1521
+ const rawPerf = await page.evaluate(() => {
1522
+ const nav = performance.getEntriesByType("navigation")[0];
1523
+ const paints = performance.getEntriesByType("paint");
1524
+ const lcpFromObserver = window.__zetaLCP ?? null;
1525
+ const lcpEntries = performance.getEntriesByType("largest-contentful-paint");
1526
+ const lcpFromEntries = lcpEntries.length > 0 ? Math.round(lcpEntries[lcpEntries.length - 1].startTime) : void 0;
1527
+ const stashedLcp = window.__zetaLCP ?? null;
1528
+ const stashedCls = window.__zetaCLS;
1529
+ return {
1530
+ ttfb: nav ? Math.round(nav.responseStart - nav.fetchStart) : void 0,
1531
+ fcp: paints.find((p) => p.name === "first-contentful-paint")?.startTime ? Math.round(paints.find((p) => p.name === "first-contentful-paint").startTime) : void 0,
1532
+ lcp: lcpFromObserver != null ? Math.round(lcpFromObserver) : lcpFromEntries,
1533
+ cls: typeof stashedCls === "number" ? Math.round(stashedCls * 1e3) / 1e3 : void 0
1534
+ };
1535
+ }).catch(() => null);
1536
+ const a11yResult = await (async () => {
1537
+ try {
1538
+ const _require = createRequire(import.meta.url);
1539
+ const axePath = _require.resolve("axe-core");
1540
+ const axeScript = readFileSync(axePath, "utf8");
1541
+ await page.addScriptTag({ content: axeScript });
1542
+ const result = await page.evaluate(async () => {
1543
+ return window.axe.run(document, {
1544
+ runOnly: { type: "tag", values: ["wcag2a", "wcag2aa", "best-practice"] },
1545
+ resultTypes: ["violations"]
1546
+ });
1547
+ });
1548
+ const violations = result.violations.map((v) => ({
1549
+ impact: v.impact,
1550
+ rule: v.id,
1551
+ description: v.description,
1552
+ // First offending node's outer HTML (truncated later in saveA11yViolations)
1553
+ nodeHtml: v.nodes?.[0]?.html ?? void 0,
1554
+ wcagTags: v.tags.filter((t) => t.startsWith("wcag") || t.startsWith("best-"))
1555
+ }));
1556
+ const criticalCount = violations.filter((v) => v.impact === "critical").length;
1557
+ return { violations, violationCount: violations.length, criticalCount };
1558
+ } catch {
1559
+ return null;
1560
+ }
1561
+ })();
1562
+ const a11yScore = a11yResult ? Math.max(0, 100 - a11yResult.criticalCount * 20 - (a11yResult.violationCount - a11yResult.criticalCount) * 5) : null;
1563
+ const name = await (async () => {
1564
+ let brandName = "";
1565
+ let pathName = "Home";
1566
+ try {
1567
+ const u = new URL(url);
1568
+ const hostname = u.hostname.replace(/^www\./, "").split(".")[0].toLowerCase();
1569
+ const allSegs = u.pathname.split("/").filter(Boolean);
1570
+ const IS_ID = (s) => /^\d+$/.test(s) || // numeric
1571
+ /^[0-9a-f]{8,}$/i.test(s) || // hex id
1572
+ /^[a-z]{1,2}[0-9a-z]{20,}$/i.test(s);
1573
+ const readable = allSegs.filter((s) => !IS_ID(s)).map((s) => s.charAt(0).toUpperCase() + s.slice(1).replace(/[-_]/g, " "));
1574
+ if (readable.length > 0) {
1575
+ const last = readable[readable.length - 1];
1576
+ const GENERIC = ["edit", "new", "create", "view", "show", "index", "detail", "details"];
1577
+ if (GENERIC.includes(last.toLowerCase()) && readable.length > 1) {
1578
+ pathName = `${readable[readable.length - 2]} \xB7 ${last}`;
1579
+ } else {
1580
+ pathName = last;
1581
+ }
1582
+ } else if (allSegs.length > 0) {
1583
+ const lastSeg = allSegs[allSegs.length - 1];
1584
+ pathName = lastSeg.slice(0, 8).toUpperCase();
1585
+ }
1586
+ const SIG_PARAMS = ["product_cat", "category", "cat", "type", "tag", "brand", "filter", "tab", "section", "view"];
1587
+ const paramSuffix = SIG_PARAMS.map((p) => {
1588
+ const v = u.searchParams.get(p);
1589
+ return v ? ` \xB7 ${v.replace(/[-_]/g, " ").replace(/\b\w/g, (c) => c.toUpperCase())}` : "";
1590
+ }).find(Boolean) ?? "";
1591
+ pathName = pathName + paramSuffix;
1592
+ brandName = hostname;
1593
+ } catch {
1594
+ }
1595
+ try {
1596
+ const title = await page.title();
1597
+ if (title && title.length > 0 && title.length < 120) {
1598
+ const parts = title.split(/\s*[|—\-–·]\s*/);
1599
+ if (parts.length > 1) brandName = (parts[parts.length - 1] ?? brandName).trim().toLowerCase();
1600
+ if (parts.length > 1) {
1601
+ const candidate2 = parts[0].trim();
1602
+ if (candidate2.length > 2 && !/^\d[\d\s]*$/.test(candidate2)) return candidate2;
1603
+ }
1604
+ const candidate = parts[0].trim();
1605
+ const isBrand = candidate.split(/\s+/).length === 1 && (candidate.toLowerCase().includes(brandName) || candidate.toLowerCase() === brandName);
1606
+ if (!isBrand && candidate.split(/\s+/).length > 1 && !/^\d[\d\s]*$/.test(candidate)) {
1607
+ return candidate;
1608
+ }
1609
+ }
1610
+ } catch {
1611
+ }
1612
+ try {
1613
+ const activeNav = await page.locator('[aria-current="page"]').first().textContent({ timeout: 2e3 }).catch(() => null);
1614
+ if (activeNav) {
1615
+ const t = activeNav.trim();
1616
+ if (t.length > 1 && t.length < 60 && t.toLowerCase() !== brandName) return t;
1617
+ }
1618
+ } catch {
1619
+ }
1620
+ try {
1621
+ const selectors = [
1622
+ "main h1",
1623
+ '[role="main"] h1',
1624
+ ".page-title",
1625
+ ".page-header h1",
1626
+ ".content h1",
1627
+ "main h2",
1628
+ '[role="main"] h2',
1629
+ ".page-header h2"
1630
+ ];
1631
+ for (const sel of selectors) {
1632
+ const h = await page.locator(sel).first().textContent({ timeout: 2e3 }).catch(() => null);
1633
+ if (!h) continue;
1634
+ const t = h.trim();
1635
+ if (t.length < 2 || t.length > 80) continue;
1636
+ if (t.toLowerCase() === brandName) continue;
1637
+ if (brandName && t.toLowerCase().replace(/\s/g, "").includes(brandName.replace(/\s/g, ""))) continue;
1638
+ return t;
1639
+ }
1640
+ } catch {
1641
+ }
1642
+ try {
1643
+ const hostname = new URL(url).hostname.replace(/^www\./, "").split(".")[0].toLowerCase();
1644
+ if (pathName !== "Home" || name.toLowerCase() === hostname || name.toLowerCase().replace(/\s/g, "") === hostname) {
1645
+ if (name.toLowerCase() === hostname || name.toLowerCase().replace(/\s/g, "") === hostname) {
1646
+ return pathName;
1647
+ }
1648
+ }
1649
+ } catch {
1650
+ }
1651
+ return pathName;
1652
+ })();
1653
+ const screen = await AppBrain.saveScreen(opts.tenantId, opts.projectId, name, url, opts.jobId, discoveryMethodFor(url));
1654
+ visited.set(canonicalize(url), screen.id);
1655
+ const shotPath = path.join(opts.screenshotDir ?? "/tmp", `${screen.id}-desktop-v${screen.version}.png`);
1656
+ await (async () => {
1657
+ const deadline = Date.now() + 4e3;
1658
+ while (Date.now() < deadline) {
1659
+ const ready = await page.evaluate(() => {
1660
+ const busy = document.querySelectorAll('[aria-busy="true"], [data-loading="true"]');
1661
+ const skeletons = document.querySelectorAll('.skeleton, [class*="skeleton"], [class*="Skeleton"]');
1662
+ return busy.length === 0 && skeletons.length === 0;
1663
+ }).catch(() => true);
1664
+ if (ready) break;
1665
+ await new Promise((r) => setTimeout(r, 200));
1666
+ }
1667
+ })();
1668
+ void (async () => {
1669
+ const screenId = screen.id;
1670
+ const [seoR, contrastR, flagsR, langR, formsR] = await Promise.allSettled([
1671
+ checkSeo(page, url),
1672
+ auditColorContrast(page, url),
1673
+ detectFeatureFlags(page, url),
1674
+ detectLanguage(page, url),
1675
+ detectForms(page)
1676
+ ]);
1677
+ const base = { screenId, jobId: opts.jobId };
1678
+ if (seoR.status === "fulfilled")
1679
+ AppBrain.saveCrawlEvidence(opts.tenantId, opts.projectId, { ...base, type: "SEO_CHECK", payload: seoR.value }).catch(() => {
1680
+ });
1681
+ if (contrastR.status === "fulfilled" && contrastR.value.issues?.length)
1682
+ AppBrain.saveCrawlEvidence(opts.tenantId, opts.projectId, { ...base, type: "COLOR_CONTRAST", payload: contrastR.value }).catch(() => {
1683
+ });
1684
+ if (flagsR.status === "fulfilled" && flagsR.value.flags?.length)
1685
+ AppBrain.saveCrawlEvidence(opts.tenantId, opts.projectId, { ...base, type: "FEATURE_FLAGS", payload: flagsR.value }).catch(() => {
1686
+ });
1687
+ if (langR.status === "fulfilled")
1688
+ AppBrain.saveCrawlEvidence(opts.tenantId, opts.projectId, { ...base, type: "LANGUAGE_DETECTION", payload: langR.value }).catch(() => {
1689
+ });
1690
+ if (formsR.status === "fulfilled" && Array.isArray(formsR.value) && formsR.value.length)
1691
+ AppBrain.saveCrawlEvidence(opts.tenantId, opts.projectId, { ...base, type: "FORMS_DETECTED", payload: { forms: formsR.value } }).catch(() => {
1692
+ });
1693
+ })();
1694
+ try {
1695
+ await page.addStyleTag({ content: "*, *::before, *::after { animation-duration: 0s !important; animation-delay: 0s !important; transition-duration: 0s !important; transition-delay: 0s !important; }" });
1696
+ } catch {
1697
+ }
1698
+ const canonUrl2 = canonicalize(url);
1699
+ const linkRecipe = await AppBrain.getActionRecipe(
1700
+ opts.tenantId,
1701
+ opts.projectId,
1702
+ new URL(url).hostname,
1703
+ `link-discovery:${canonUrl2}`
1704
+ ).catch(() => null);
1705
+ const cachedLinks = linkRecipe && linkRecipe.successRate >= 0.7 ? linkRecipe.steps.map((s) => s.href).filter((href) => !!href && isUsefulNavigationUrl(href, origin)) : null;
1706
+ if (cachedLinks) {
1707
+ logger.info({ url, cachedCount: cachedLinks.length }, "[crawler] link-discovery: using cached recipe \u2014 AI will still run and merge");
1708
+ }
1709
+ await onProgress?.(20, `Crawling ${visited.size} of ~${visited.size + queue.length} \xB7 ${(() => {
1710
+ try {
1711
+ return new URL(url).pathname || "/";
1712
+ } catch {
1713
+ return "/";
1714
+ }
1715
+ })()}`);
1716
+ const preDomCount = await page.evaluate(
1717
+ () => document.querySelectorAll('button,a,input,select,textarea,[role="button"],[onclick],[aria-label]').length
1718
+ ).catch(() => 0);
1719
+ await waitForScreenshotReady(page);
1720
+ const [, elements, nextLinks, atElements] = await Promise.all([
1721
+ page.screenshot({ path: shotPath, fullPage: true }).then(() => AppBrain.saveScreenshot(opts.tenantId, opts.projectId, screen.id, shotPath, screen.version, "DESKTOP")),
1722
+ extractElements(stagehand, page, elementLLM ?? void 0).catch(() => []),
1723
+ // Always run AI link discovery (even when cache exists) — cache is merged in, not used as a replacement.
1724
+ // Skipping AI when cache exists was a bug: stale cache from broken crawls permanently blocked discovery.
1725
+ discoverLinks(stagehand, origin, page, linkLLM ?? void 0, aiSkipCount > 0).then((freshLinks) => {
1726
+ if (aiSkipCount > 0) aiSkipCount--;
1727
+ aiErrorStreak = 0;
1728
+ const merged = (cachedLinks ? Array.from(/* @__PURE__ */ new Set([...cachedLinks, ...freshLinks])) : freshLinks).filter((href) => isUsefulNavigationUrl(href, origin));
1729
+ logger.info({ url, domLinks: freshLinks.length, cached: cachedLinks?.length ?? 0, merged: merged.length }, "[crawler] link-discovery: result");
1730
+ if (merged.length > 0) {
1731
+ AppBrain.saveActionRecipe(opts.tenantId, opts.projectId, {
1732
+ domain: new URL(url).hostname,
1733
+ purpose: `link-discovery:${canonUrl2}`,
1734
+ fingerprint: sha256(canonUrl2),
1735
+ steps: merged.map((href) => ({ action: "link", href })),
1736
+ successRate: 0.8
1737
+ }).catch(() => {
1738
+ });
1739
+ }
1740
+ return merged;
1741
+ }).catch((err) => {
1742
+ const fallback = err?.domLinks ?? [];
1743
+ if (err?.isRateLimit) {
1744
+ aiErrorStreak++;
1745
+ if (aiErrorStreak >= 3) {
1746
+ aiSkipCount = 10;
1747
+ logger.warn("[crawler] 3 consecutive AI rate limits \u2014 skipping AI link discovery for next 10 screens");
1748
+ }
1749
+ if (!aiRateLimitWarned) {
1750
+ aiRateLimitWarned = true;
1751
+ onProgress?.(20, "AI quota exhausted \u2014 falling back to DOM-only link discovery").catch(() => {
1752
+ });
1753
+ }
1754
+ }
1755
+ return fallback;
1756
+ }),
1757
+ readScreen(page).catch(() => [])
1758
+ ]);
1759
+ logger.info({ url, screenId: screen.id, version: screen.version, elements: elements.length, links: nextLinks.length, atSample: atElements.slice(0, 3).map((a) => a.label) }, "[crawler] screen captured");
1760
+ console.log("[crawler] AT:", JSON.stringify(atElements.slice(0, 5)));
1761
+ try {
1762
+ const domContent = await capturePageHtml(page);
1763
+ if (!domContent) {
1764
+ logger.warn({ url, screenId: screen.id }, "[crawler] markdown extraction skipped: DOM snapshot unavailable");
1765
+ } else {
1766
+ const domPath = shotPath.replace(/\.(png|jpg|jpeg)$/i, ".dom.html");
1767
+ await fs.writeFile(domPath, domContent, "utf8");
1768
+ AppBrain.updateScreenDomSnapshot(opts.tenantId, screen.id, domPath).catch(() => {
1769
+ });
1770
+ const [iframeContent, shadowContent] = await Promise.all([
1771
+ extractIframeContent(page).catch(() => []),
1772
+ pierceShadowDom(page).catch(() => "")
1773
+ ]);
1774
+ const supplemental = [
1775
+ ...iframeContent.map((f) => f.markdown).filter(Boolean),
1776
+ shadowContent
1777
+ ].join("\n\n").trim();
1778
+ const extracted = extractMarkdown(
1779
+ supplemental ? domContent + "\n<!-- iframe/shadow -->\n" + supplemental.slice(0, 3e3) : domContent,
1780
+ url
1781
+ );
1782
+ let fitMarkdown;
1783
+ if (extracted) {
1784
+ const mdPath = shotPath.replace(/\.(png|jpg|jpeg)$/i, ".md");
1785
+ fitMarkdown = buildFitMarkdown(extracted.markdown, [
1786
+ ...domainProfile.importantScreenPatterns,
1787
+ ...domainProfile.workflowGoals,
1788
+ ...opts.bestFirstKeywords ?? []
1789
+ ]);
1790
+ const { text: scrubbed, scrubbed: hadPii } = scrubPii(extracted.markdown);
1791
+ if (hadPii) logger.info({ url }, "[crawler] PII detected and redacted from markdown");
1792
+ const dedupResult = deduplicator.check(url, scrubbed);
1793
+ if (dedupResult.isDuplicate) {
1794
+ logger.info({ url, similarUrl: dedupResult.similarUrl, score: dedupResult.similarityScore }, "[crawler] near-duplicate page skipped");
1795
+ }
1796
+ await fs.writeFile(mdPath, scrubbed, "utf8");
1797
+ const summaryPromise = (async () => {
1798
+ try {
1799
+ const llmCfg = await getTaskAgentLLMConfig(opts.tenantId, "crawl").catch(() => void 0);
1800
+ const snippet = extracted.markdown.slice(0, 1200);
1801
+ const { text, usage } = await askTracked(
1802
+ "You are a QA analyst. Given page content, write ONE crisp sentence describing what this screen does or shows. Be specific. No preamble.",
1803
+ `Screen: "${name}"
1804
+ URL: ${url}
1805
+
1806
+ Content:
1807
+ ${snippet}`,
1808
+ llmCfg,
1809
+ 80
1810
+ );
1811
+ if (usage) {
1812
+ recordLLMCall({
1813
+ tenantId: opts.tenantId,
1814
+ projectId: opts.projectId,
1815
+ jobId: opts.jobId,
1816
+ agentType: "crawl_summary",
1817
+ label: `page_summary:${name}`,
1818
+ usage
1819
+ }).catch(() => {
1820
+ });
1821
+ }
1822
+ return text.trim().replace(/^["']|["']$/g, "");
1823
+ } catch {
1824
+ return void 0;
1825
+ }
1826
+ })();
1827
+ AppBrain.updateScreenMarkdown(opts.tenantId, opts.projectId, screen.id, {
1828
+ markdownPath: mdPath,
1829
+ markdownMethod: extracted.method,
1830
+ markdownWordCount: extracted.wordCount,
1831
+ markdown: fitMarkdown ?? extracted.markdown,
1832
+ heading: extracted.title || name
1833
+ }).catch((err) => console.warn("[crawler] markdown extraction save failed:", err));
1834
+ summaryPromise.then((aiSummary) => {
1835
+ if (aiSummary) AppBrain.updateScreenAiSummary(opts.tenantId, screen.id, aiSummary).catch(() => {
1836
+ });
1837
+ });
1838
+ }
1839
+ const structuredJson = summarizeStructuredElements(atElements);
1840
+ const artifact = buildCrawlArtifactEnvelope({
1841
+ tenantId: opts.tenantId,
1842
+ projectId: opts.projectId,
1843
+ jobId: opts.jobId,
1844
+ url,
1845
+ canonicalUrl: canonicalize(url),
1846
+ screenId: screen.id,
1847
+ screenshotPath: shotPath,
1848
+ domPath,
1849
+ domContent,
1850
+ markdown: extracted?.markdown,
1851
+ fitMarkdown,
1852
+ accessibilityElements: atElements,
1853
+ structuredJson,
1854
+ evidence: [
1855
+ { type: "domain_profile", payload: { industry: domainProfile.industry, confidence: domainProfile.confidence } },
1856
+ { type: "engine_route", payload: routeDecision },
1857
+ { type: "screen_fingerprints", payload: { urlCanonicalHash: sha256(canonicalize(url)) } }
1858
+ ]
1859
+ });
1860
+ AppBrain.saveCrawlEvidence(opts.tenantId, opts.projectId, {
1861
+ screenId: screen.id,
1862
+ jobId: opts.jobId,
1863
+ type: "SCREEN_ARTIFACT_ENVELOPE",
1864
+ payload: artifact
1865
+ }).catch((err) => console.warn("[crawler] artifact evidence save failed:", err?.message ?? err));
1866
+ }
1867
+ } catch (err) {
1868
+ logger.warn({ url, screenId: screen.id, err: String(err?.message ?? err) }, "[crawler] markdown extraction failed");
1869
+ }
1870
+ if (a11yScore !== null) {
1871
+ AppBrain.updateScreenA11y(opts.tenantId, screen.id, { a11yScore }).catch((err) => console.warn("[crawler] a11y score update failed:", err));
1872
+ }
1873
+ if (a11yResult !== null && a11yResult.violations.length > 0) {
1874
+ AppBrain.saveA11yViolations(opts.tenantId, opts.projectId, screen.id, a11yResult.violations, opts.jobId).catch((err) => console.warn("[crawler] a11y violations save failed:", err));
1875
+ }
1876
+ const atByRole = /* @__PURE__ */ new Map();
1877
+ for (const at of atElements) {
1878
+ if (!atByRole.has(at.role)) atByRole.set(at.role, at.selectorHint);
1879
+ }
1880
+ AppBrain.autoCreateVisualBaseline(opts.tenantId, opts.projectId, screen.id, "DESKTOP", shotPath, gitInfo.branch, gitInfo.commit || void 0).catch(() => {
1881
+ });
1882
+ const newElementHash = computeElementHash(elements);
1883
+ AppBrain.updateElementHash(opts.tenantId, screen.id, newElementHash, screen.elementHash ?? null).catch((err) => console.warn("[crawler] element hash update failed:", err));
1884
+ if (screen.version > 1) {
1885
+ (async () => {
1886
+ try {
1887
+ const prevShot = await AppBrain.getPreviousScreenshotPath(opts.tenantId, screen.id, screen.version - 1);
1888
+ if (prevShot) {
1889
+ const { screenshotDiffPercent } = await import("./phash.js");
1890
+ const { pHashDistance, changeLevel } = await screenshotDiffPercent(prevShot, shotPath);
1891
+ await AppBrain.saveScreenDiff(opts.tenantId, opts.projectId, screen.id, screen.version - 1, screen.version, pHashDistance, changeLevel);
1892
+ }
1893
+ } catch (err) {
1894
+ console.warn("[crawler] pHash diff skipped:", err);
1895
+ }
1896
+ })();
1897
+ }
1898
+ if (screen.version > 1) {
1899
+ (async () => {
1900
+ try {
1901
+ const baseline = await AppBrain.getVisualBaselineForDiff(opts.tenantId, screen.id, "DESKTOP", gitInfo.branch);
1902
+ if (baseline && baseline.storagePath !== shotPath) {
1903
+ const { screenshotDiffPercent, gridLayoutDiff } = await import("./phash.js");
1904
+ const [{ pHashDistance, changeLevel }, layoutChanges, elementShifts] = await Promise.all([
1905
+ screenshotDiffPercent(baseline.storagePath, shotPath, baseline.ignoredRegions),
1906
+ gridLayoutDiff(baseline.storagePath, shotPath, baseline.ignoredRegions),
1907
+ AppBrain.computeElementLayoutDiff(opts.tenantId, screen.id, "DESKTOP")
1908
+ ]);
1909
+ let verdict = pHashVerdict(pHashDistance, changeLevel, [
1910
+ ...layoutChanges,
1911
+ ...elementShifts.map((s) => ({ location: "element", severity: s.severity, description: s.description }))
1912
+ ]);
1913
+ if (verdict.changeType === "SIGNIFICANT" || verdict.changeType === "CRITICAL") {
1914
+ try {
1915
+ const regions = baseline.ignoredRegions ?? [];
1916
+ const [baselineBuffer, newBuffer] = regions.length > 0 ? await (async () => {
1917
+ const { applyMask } = await import("./phash.js");
1918
+ return Promise.all([applyMask(baseline.storagePath, regions), applyMask(shotPath, regions)]);
1919
+ })() : await Promise.all([
1920
+ fs.readFile(baseline.storagePath),
1921
+ fs.readFile(shotPath)
1922
+ ]);
1923
+ const aiResult = await aiVisualCompare({
1924
+ baselineImageBase64: baselineBuffer.toString("base64"),
1925
+ newImageBase64: newBuffer.toString("base64"),
1926
+ screenName: screen.name,
1927
+ screenPurpose: "Application screen captured during automated crawl",
1928
+ tenantId: opts.tenantId,
1929
+ projectId: opts.projectId
1930
+ });
1931
+ verdict = mergeAiVisualVerdict(verdict, aiResult, screen.name);
1932
+ } catch (aiErr) {
1933
+ logger.warn({ screenId: screen.id, err: String(aiErr) }, "[crawler] AI visual compare failed, keeping pHash-only verdict");
1934
+ }
1935
+ }
1936
+ const { changeType, recommendation, changes: allChanges, bugReportDraft, aiSummary } = verdict;
1937
+ if (changeType !== "NONE") {
1938
+ let newScreenshotCloudUrl;
1939
+ try {
1940
+ const { getTenantStorageConfig, uploadScreenshotToTenantStorage, buildArtifactKey } = await import("@qip/app-brain");
1941
+ const storageCfg = await getTenantStorageConfig(opts.tenantId).catch(() => null);
1942
+ if (storageCfg) {
1943
+ const key = buildArtifactKey(opts.tenantId, opts.projectId, "visual-diffs", `${screen.id}-v${screen.version}.png`, storageCfg.prefix);
1944
+ newScreenshotCloudUrl = await uploadScreenshotToTenantStorage(storageCfg, shotPath, key);
1945
+ }
1946
+ } catch {
1947
+ }
1948
+ await AppBrain.saveVisualDiff(opts.tenantId, {
1949
+ baselineId: baseline.id,
1950
+ newScreenshotPath: shotPath,
1951
+ newScreenshotCloudUrl,
1952
+ diffScore: pHashDistance,
1953
+ changeType,
1954
+ changes: allChanges,
1955
+ recommendation,
1956
+ bugReportDraft,
1957
+ aiSummary
1958
+ });
1959
+ }
1960
+ }
1961
+ } catch (err) {
1962
+ const missing = String(err?.message ?? err).includes("Input file is missing");
1963
+ if (missing) {
1964
+ logger.warn({ screenId: screen.id, version: screen.version, err: String(err?.message ?? err) }, "[crawler] VisualDiff skipped \u2014 baseline screenshot not on disk (stored in cloud only). Run a full recrawl to regenerate baselines.");
1965
+ } else {
1966
+ console.warn("[crawler] VisualDiff pipeline failed:", err);
1967
+ }
1968
+ }
1969
+ })();
1970
+ }
1971
+ const linkMappings = await extractLinkMappings(page, origin);
1972
+ for (const el of elements) {
1973
+ const saved = await AppBrain.saveElement(opts.tenantId, opts.projectId, screen.id, el.meaning, el.role, el.expectedData, el.notes, void 0, true, el.boundingRect, el.ariaState, el.parentLandmark);
1974
+ const label = el.meaning;
1975
+ const role = el.role ?? "unknown";
1976
+ const selectorHint = atByRole.get(role) ?? role;
1977
+ const selectors = [
1978
+ { type: "role-label", value: `${role}:${label}`, confidence: 0.9 },
1979
+ { type: "aria-label", value: label, confidence: 0.85 },
1980
+ { type: "tag-hint", value: selectorHint, confidence: 0.6 }
1981
+ ];
1982
+ AppBrain.saveElementFingerprint(opts.tenantId, opts.projectId, saved.id, selectors).catch(console.warn);
1983
+ }
1984
+ for (const lm of linkMappings) {
1985
+ const safety = assessActionSafety(lm.label, domainProfile);
1986
+ if (!safety.allowed) {
1987
+ AppBrain.saveCrawlEvidence(opts.tenantId, opts.projectId, {
1988
+ screenId: screen.id,
1989
+ jobId: opts.jobId,
1990
+ type: "ACTION_BLOCKED_BY_SAFETY_POLICY",
1991
+ payload: { label: lm.label, href: lm.href, decision: safety, profile: domainProfile.industry }
1992
+ }).catch(() => {
1993
+ });
1994
+ continue;
1995
+ }
1996
+ pendingTransitions.push({ fromScreenId: screen.id, elementMeaning: lm.label, targetUrl: lm.href });
1997
+ AppBrain.saveActionRecipe(opts.tenantId, opts.projectId, {
1998
+ domain: new URL(url).hostname,
1999
+ purpose: `navigate:${lm.label}`,
2000
+ fingerprint: sha256(`${canonicalize(url)}:${lm.label}:${canonicalize(lm.href)}`),
2001
+ steps: [{ action: "click", label: lm.label, targetUrl: lm.href, fromUrl: url }],
2002
+ successRate: 0.5
2003
+ }).catch(() => {
2004
+ });
2005
+ if (!selective) {
2006
+ try {
2007
+ const targetPath = new URL(lm.href).pathname;
2008
+ const canonLmHref = canonicalize(lm.href);
2009
+ if (!SKIP_PATH.test(targetPath) && isUsefulNavigationUrl(lm.href, origin) && !queued.has(canonLmHref) && !visited.has(canonLmHref) && queued.size < maxScreens * 2) {
2010
+ if (robotsCache && !await robotsCache.isAllowed(lm.href)) continue;
2011
+ queued.add(canonLmHref);
2012
+ queue.push(lm.href, { parentElements: elements.length });
2013
+ edges.push({ fromUrl: url, toUrl: lm.href });
2014
+ logger.info({ fromUrl: url, newUrl: lm.href, label: lm.label, queueSize: queue.length }, "[crawler] anchor mapping added to queue");
2015
+ }
2016
+ } catch {
2017
+ }
2018
+ }
2019
+ }
2020
+ if (!selective) {
2021
+ for (const href of nextLinks) {
2022
+ edges.push({ fromUrl: url, toUrl: href });
2023
+ try {
2024
+ const hrefParams = new URL(href).searchParams;
2025
+ const actionVal = hrefParams.get("action") ?? "";
2026
+ if (/^yith-|^wc-|^vc_|^elementor/.test(actionVal)) continue;
2027
+ } catch {
2028
+ }
2029
+ const canonHref = canonicalize(href);
2030
+ if (!queued.has(canonHref) && !visited.has(canonHref) && queued.size < maxScreens * 2) {
2031
+ try {
2032
+ const hrefPath = new URL(href).pathname;
2033
+ if (isGeneratedDynamicPath(hrefPath)) continue;
2034
+ } catch {
2035
+ }
2036
+ if (robotsCache && !await robotsCache.isAllowed(href)) continue;
2037
+ queued.add(canonHref);
2038
+ queue.push(href, { parentElements: elements.length });
2039
+ logger.info({ newUrl: href, queueSize: queue.length }, "[crawler] new URL added to queue");
2040
+ }
2041
+ }
2042
+ logger.info({ fromUrl: url, discovered: nextLinks.length, queueAfter: queue.length, visitedSoFar: visited.size }, "[crawler] link processing done");
2043
+ try {
2044
+ const jsRoutes = await page.evaluate(() => {
2045
+ const routes = window.__zetaDiscoveredRoutes ?? [];
2046
+ window.__zetaDiscoveredRoutes = [];
2047
+ return routes;
2048
+ }).catch(() => []);
2049
+ for (const route of jsRoutes) {
2050
+ try {
2051
+ const abs = route.startsWith("http") ? route : `${origin}${route.startsWith("/") ? "" : "/"}${route}`;
2052
+ const canonRoute = canonicalize(abs);
2053
+ if (!queued.has(canonRoute) && !visited.has(canonRoute)) {
2054
+ queued.add(canonRoute);
2055
+ queue.push(abs);
2056
+ logger.info({ route: abs }, "[crawler] history.pushState: discovered SPA route");
2057
+ }
2058
+ } catch {
2059
+ }
2060
+ }
2061
+ } catch {
2062
+ }
2063
+ if (preDomCount < 25 && !revisitQueue.includes(url)) {
2064
+ try {
2065
+ const postDomCount = await page.evaluate(
2066
+ () => document.querySelectorAll('button,a,input,select,textarea,[role="button"],[onclick],[aria-label]').length
2067
+ ).catch(() => 0);
2068
+ if (postDomCount > preDomCount * 1.3 + 4) {
2069
+ revisitQueue.push(url);
2070
+ logger.info({ url, preDomCount, postDomCount }, "[P3a] Async content detected \u2014 scheduled for re-visit");
2071
+ }
2072
+ } catch {
2073
+ }
2074
+ }
2075
+ if (visited.size <= 5) {
2076
+ try {
2077
+ const jsDetectedRoutes = await detectJsRoutes(page, origin);
2078
+ for (const route of jsDetectedRoutes) {
2079
+ const canonRoute = canonicalize(route);
2080
+ if (isUsefulNavigationUrl(route, origin) && !queued.has(canonRoute) && !visited.has(canonRoute) && queued.size < maxScreens * 2) {
2081
+ queued.add(canonRoute);
2082
+ queue.push(route, { parentElements: elements.length });
2083
+ logger.info({ route }, "[P2] JS router: discovered route added to queue");
2084
+ }
2085
+ }
2086
+ } catch {
2087
+ }
2088
+ }
2089
+ for (const relPath of [...xhrDiscoveredPaths]) {
2090
+ xhrDiscoveredPaths.delete(relPath);
2091
+ try {
2092
+ const abs = `${origin}${relPath}`;
2093
+ const canon = canonicalize(abs);
2094
+ if (isUsefulNavigationUrl(abs, origin) && !queued.has(canon) && !visited.has(canon) && queued.size < maxScreens * 2) {
2095
+ queued.add(canon);
2096
+ queue.push(abs);
2097
+ logger.info({ url: abs }, "[P4] XHR: page URL from JSON response added to queue");
2098
+ }
2099
+ } catch {
2100
+ }
2101
+ }
2102
+ if (nextLinks.length === 0 && elements.length < 5 && opts.skyvernConfig) {
2103
+ try {
2104
+ logger.info({ url }, "Gap 5: thin page detected \u2014 asking Skyvern to discover navigation URLs");
2105
+ const skyvernNav = await skyvernNavigate(
2106
+ url,
2107
+ ["Find and return all navigation links, menu items, and page URLs accessible from this screen"],
2108
+ opts.skyvernConfig
2109
+ );
2110
+ for (const skyvernUrl of skyvernNav.visitedUrls) {
2111
+ const canonSkyvernUrl = canonicalize(skyvernUrl);
2112
+ if (!queued.has(canonSkyvernUrl) && !visited.has(canonSkyvernUrl) && queued.size < maxScreens * 2) {
2113
+ logger.info({ url: skyvernUrl }, "Gap 5: Skyvern-discovered URL added to ZeTa crawl queue");
2114
+ queued.add(canonSkyvernUrl);
2115
+ queue.push(skyvernUrl);
2116
+ pendingTransitions.push({ fromScreenId: screen.id, elementMeaning: "Skyvern-discovered link", targetUrl: skyvernUrl });
2117
+ }
2118
+ }
2119
+ } catch {
2120
+ }
2121
+ }
2122
+ }
2123
+ if (rawPerf) {
2124
+ performanceMetrics.push({ url, screenName: name, ...rawPerf });
2125
+ }
2126
+ const screenApiEndpoints = capturedApiEndpoints.slice(apiObservationStartIndex);
2127
+ if (screenApiEndpoints.length > 0) {
2128
+ AppBrain.saveApiEndpointObservations(opts.tenantId, opts.projectId, screen.id, screenApiEndpoints).then(() => AppBrain.saveCrawlEvidence(opts.tenantId, opts.projectId, {
2129
+ screenId: screen.id,
2130
+ jobId: opts.jobId,
2131
+ type: "API_ENDPOINT_OBSERVATIONS",
2132
+ payload: { endpoints: screenApiEndpoints }
2133
+ })).catch((err) => console.warn("[crawler] API endpoint observation save failed:", err?.message ?? err));
2134
+ }
2135
+ if (opts.jobId && (rawPerf || process.env.ENABLE_LIGHTHOUSE)) {
2136
+ (async () => {
2137
+ try {
2138
+ const lhScores = await runLighthouse(url);
2139
+ await AppBrain.savePerformanceSnapshot(opts.tenantId, screen.id, opts.jobId, {
2140
+ lcp: rawPerf?.lcp,
2141
+ fcp: rawPerf?.fcp,
2142
+ cls: rawPerf?.cls,
2143
+ ttfb: rawPerf?.ttfb,
2144
+ viewport: "DESKTOP",
2145
+ ...lhScores ? {
2146
+ lighthousePerf: lhScores.perf,
2147
+ lighthouseA11y: lhScores.a11y,
2148
+ lighthouseSEO: lhScores.seo,
2149
+ lighthouseBP: lhScores.bp,
2150
+ lighthouseReport: lhScores.report
2151
+ } : {}
2152
+ });
2153
+ } catch (perfErr) {
2154
+ console.warn("[crawler] savePerformanceSnapshot failed (non-fatal):", perfErr);
2155
+ }
2156
+ })();
2157
+ }
2158
+ discovered.push({ url, screenId: screen.id, elements: elements.length, elementFacts: elements, screenshotPath: shotPath, version: screen.version });
2159
+ maxSeenTotal = Math.max(maxSeenTotal, visited.size + queue.length);
2160
+ const screenPct = Math.min(60, visited.size / Math.max(1, maxSeenTotal) * 60);
2161
+ await onProgress?.(20 + Math.round(screenPct), `Crawling screens (${visited.size} mapped, ${queue.length} pending)`);
2162
+ logger.info({ name, elements: elements.length }, "Screen crawled");
2163
+ if (++checkpointSuccessCount % 10 === 0) {
2164
+ await opts.onCheckpoint?.(
2165
+ Array.from(visited.keys()),
2166
+ queue.filter((u) => !visited.has(canonicalize(u)))
2167
+ );
2168
+ }
2169
+ } catch (err) {
2170
+ logger.warn({ url, err: err?.message ?? String(err), stack: err?.stack?.split("\n").slice(0, 5).join(" | ") }, "Screen crawl failed");
2171
+ discovered.push({ url, error: err?.message ?? String(err) });
2172
+ }
2173
+ }
2174
+ if (opts.discoveryMode) {
2175
+ return {
2176
+ discoveredUrls: Array.from(visited.values()),
2177
+ screensFound: 0,
2178
+ screensErrored: 0,
2179
+ screensSkippedToNextSegment: 0,
2180
+ isPartial: false,
2181
+ discovered: [],
2182
+ apiEndpoints: [],
2183
+ performanceMetrics: [],
2184
+ pendingUrls: [],
2185
+ segmentIndex: segIdx,
2186
+ hasMoreSegments: false,
2187
+ domHashes: Array.from(seenDomHashes)
2188
+ };
2189
+ }
2190
+ const successfulScreenCount = discovered.filter((d) => d.screenId).length;
2191
+ if (successfulScreenCount < 10 && opts.skyvernConfig) {
2192
+ await onProgress?.(78, "Few screens found \u2014 using Skyvern to discover additional navigation URLs");
2193
+ const currentUrl = page.url();
2194
+ const skyvernNav = await skyvernNavigate(
2195
+ currentUrl,
2196
+ [
2197
+ "Explore the main navigation and visit each top-level section",
2198
+ "Click any sidebar or top menu items to discover sub-pages"
2199
+ ],
2200
+ opts.skyvernConfig
2201
+ );
2202
+ const skyvernUrls = skyvernNav.visitedUrls.filter((u) => !visited.has(canonicalize(u)));
2203
+ if (skyvernUrls.length > 0) {
2204
+ logger.info({ count: skyvernUrls.length }, "Gap 5: Skyvern found new URLs \u2014 processing with ZeTa native crawler");
2205
+ for (const skyvernUrl of skyvernUrls) {
2206
+ if (visited.size >= maxScreens) break;
2207
+ try {
2208
+ await page.goto(skyvernUrl, { waitUntil: "domcontentloaded", timeout: 2e4 });
2209
+ await page.waitForTimeout(1500);
2210
+ const skyvernName = new URL(skyvernUrl).pathname.split("/").filter(Boolean).pop() ?? "Screen";
2211
+ const screen = await AppBrain.saveScreen(opts.tenantId, opts.projectId, skyvernName, skyvernUrl, opts.jobId, "SKYVERN_AI");
2212
+ const shotPath = `${opts.screenshotDir ?? "/tmp"}/${screen.id}-v${screen.version}.png`;
2213
+ await waitForScreenshotReady(page);
2214
+ await page.screenshot({ path: shotPath, fullPage: true });
2215
+ await AppBrain.saveScreenshot(opts.tenantId, opts.projectId, screen.id, shotPath, screen.version, "DESKTOP");
2216
+ const [elems, links, linkMappings] = await Promise.all([
2217
+ extractElements(stagehand, page, elementLLM ?? void 0),
2218
+ discoverLinks(stagehand, origin, page, linkLLM ?? void 0, aiSkipCount > 0),
2219
+ extractLinkMappings(page, origin)
2220
+ ]);
2221
+ for (const el of elems) {
2222
+ await AppBrain.saveElement(opts.tenantId, opts.projectId, screen.id, el.meaning, el.role, el.expectedData, el.notes, void 0, true, el.boundingRect, el.ariaState, el.parentLandmark).catch(() => {
2223
+ });
2224
+ }
2225
+ visited.set(canonicalize(skyvernUrl), screen.id);
2226
+ discovered.push({ url: skyvernUrl, screenId: screen.id, elements: elems.length, version: screen.version });
2227
+ for (const lm of linkMappings) {
2228
+ pendingTransitions.push({ fromScreenId: screen.id, elementMeaning: lm.label, targetUrl: lm.href });
2229
+ }
2230
+ for (const href of links) {
2231
+ const c = canonicalize(href);
2232
+ if (isUsefulNavigationUrl(href, origin) && !queued.has(c) && !visited.has(c)) {
2233
+ queued.add(c);
2234
+ queue.push(href);
2235
+ }
2236
+ }
2237
+ } catch (err) {
2238
+ logger.warn({ url: skyvernUrl, err: String(err) }, "Gap 5: ZeTa crawl of Skyvern URL failed");
2239
+ }
2240
+ }
2241
+ while (queue.length > 0 && visited.size < maxScreens) {
2242
+ const href = queue.shift();
2243
+ const canonHref = canonicalize(href);
2244
+ if (visited.has(canonHref)) continue;
2245
+ if (!isUsefulNavigationUrl(href, origin)) continue;
2246
+ try {
2247
+ await page.goto(href, { waitUntil: "domcontentloaded", timeout: 2e4 });
2248
+ await page.waitForTimeout(1e3);
2249
+ const nm = new URL(href).pathname.split("/").filter(Boolean).pop() ?? "Screen";
2250
+ const sc = await AppBrain.saveScreen(opts.tenantId, opts.projectId, nm, href, opts.jobId, "SKYVERN_AI");
2251
+ const sp = `${opts.screenshotDir ?? "/tmp"}/${sc.id}-v${sc.version}.png`;
2252
+ await waitForScreenshotReady(page);
2253
+ await page.screenshot({ path: sp, fullPage: true });
2254
+ await AppBrain.saveScreenshot(opts.tenantId, opts.projectId, sc.id, sp, sc.version, "DESKTOP");
2255
+ const [elems2, linkMappings2] = await Promise.all([
2256
+ extractElements(stagehand, page, elementLLM ?? void 0),
2257
+ extractLinkMappings(page, origin)
2258
+ ]);
2259
+ for (const el of elems2) {
2260
+ await AppBrain.saveElement(opts.tenantId, opts.projectId, sc.id, el.meaning, el.role, el.expectedData, el.notes, void 0, true, el.boundingRect, el.ariaState, el.parentLandmark).catch(() => {
2261
+ });
2262
+ }
2263
+ visited.set(canonHref, sc.id);
2264
+ discovered.push({ url: href, screenId: sc.id, elements: elems2.length, version: sc.version });
2265
+ for (const lm of linkMappings2) {
2266
+ pendingTransitions.push({ fromScreenId: sc.id, elementMeaning: lm.label, targetUrl: lm.href });
2267
+ }
2268
+ } catch {
2269
+ }
2270
+ }
2271
+ }
2272
+ }
2273
+ try {
2274
+ const { runUrlPatternCompletion } = await import("./url-pattern-completer.js");
2275
+ const llmCfgForPatterns = await getTaskAgentLLMConfig(opts.tenantId, "crawl").catch(() => null);
2276
+ const urlPatternCandidates = await runUrlPatternCompletion(
2277
+ Array.from(visited.keys()),
2278
+ opts.appUrl,
2279
+ llmCfgForPatterns
2280
+ );
2281
+ if (urlPatternCandidates.length > 0) {
2282
+ AppBrain.saveCrawlEvidence(opts.tenantId, opts.projectId, {
2283
+ jobId: opts.jobId,
2284
+ type: "URL_CANDIDATES",
2285
+ payload: {
2286
+ candidates: urlPatternCandidates,
2287
+ hits: urlPatternCandidates.filter((c) => c.status === "hit").length,
2288
+ total: urlPatternCandidates.length
2289
+ }
2290
+ }).catch(() => {
2291
+ });
2292
+ logger.info(
2293
+ { hits: urlPatternCandidates.filter((c) => c.status === "hit").length, total: urlPatternCandidates.length },
2294
+ "[P1] URL pattern completion saved to evidence"
2295
+ );
2296
+ }
2297
+ } catch (err) {
2298
+ logger.warn({ err: String(err) }, "[P1] URL pattern completion failed \u2014 non-fatal, continuing");
2299
+ }
2300
+ if (revisitQueue.length > 0) {
2301
+ await onProgress?.(79, `Re-visiting ${revisitQueue.length} page(s) with async content\u2026`);
2302
+ logger.info({ count: revisitQueue.length }, "[P3a] Starting async content re-visit pass");
2303
+ for (const revisitUrl of revisitQueue.slice(0, 10)) {
2304
+ const existingScreenId = visited.get(canonicalize(revisitUrl));
2305
+ if (!existingScreenId) continue;
2306
+ try {
2307
+ await page.goto(revisitUrl, { waitUntil: "networkidle", timeout: 25e3 });
2308
+ await page.waitForTimeout(2e3);
2309
+ const shotPath2 = `${opts.screenshotDir ?? "/tmp"}/${existingScreenId}-revisit.png`;
2310
+ await waitForScreenshotReady(page);
2311
+ await page.screenshot({ path: shotPath2, fullPage: true });
2312
+ await AppBrain.saveScreenshot(opts.tenantId, opts.projectId, existingScreenId, shotPath2, 1, "DESKTOP").catch(() => {
2313
+ });
2314
+ const revisitElements = await extractElements(stagehand, page, elementLLM ?? void 0).catch(() => []);
2315
+ for (const el of revisitElements) {
2316
+ await AppBrain.saveElement(opts.tenantId, opts.projectId, existingScreenId, el.meaning, el.role, el.expectedData, el.notes, void 0, true, el.boundingRect, el.ariaState, el.parentLandmark).catch(() => {
2317
+ });
2318
+ }
2319
+ const revisitLinks = await discoverLinks(stagehand, origin, page, linkLLM ?? void 0, false).catch(() => []);
2320
+ for (const href of revisitLinks) {
2321
+ const canon = canonicalize(href);
2322
+ if (isUsefulNavigationUrl(href, origin) && !queued.has(canon) && !visited.has(canon) && queued.size < maxScreens * 2) {
2323
+ queued.add(canon);
2324
+ queue.push(href);
2325
+ }
2326
+ }
2327
+ logger.info({ url: revisitUrl, elements: revisitElements.length, newLinks: revisitLinks.length }, "[P3a] Re-visit complete");
2328
+ } catch (err) {
2329
+ logger.warn({ url: revisitUrl, err: String(err) }, "[P3a] Re-visit failed \u2014 non-fatal");
2330
+ }
2331
+ }
2332
+ }
2333
+ if (visited.size >= 3) {
2334
+ try {
2335
+ await onProgress?.(80, "Running gap analysis\u2026");
2336
+ const llmCfgForGap = await getTaskAgentLLMConfig(opts.tenantId, "crawl").catch(() => null);
2337
+ const discoveredForGap = Array.from(visited.entries()).map(([url, screenId]) => {
2338
+ const d = discovered.find((dd) => dd.screenId === screenId);
2339
+ return { url, name: d ? new URL(url).pathname.split("/").filter(Boolean).pop() ?? "screen" : void 0, elements: d?.elements };
2340
+ });
2341
+ const gapCandidates = await runGapAnalysis(discoveredForGap, opts.appUrl, llmCfgForGap);
2342
+ const confirmedGaps = gapCandidates.filter((g) => g.confirmedUrl && !visited.has(canonicalize(g.confirmedUrl)));
2343
+ if (confirmedGaps.length > 0) {
2344
+ AppBrain.saveCrawlEvidence(opts.tenantId, opts.projectId, {
2345
+ jobId: opts.jobId,
2346
+ type: "URL_CANDIDATES",
2347
+ payload: {
2348
+ source: "gap-pass",
2349
+ candidates: gapCandidates.map((g) => ({ url: g.confirmedUrl ?? g.urlPatterns[0], confidence: g.confidence, reason: g.rationale, status: g.confirmedUrl ? "hit" : "miss" })),
2350
+ hits: confirmedGaps.length,
2351
+ total: gapCandidates.length
2352
+ }
2353
+ }).catch(() => {
2354
+ });
2355
+ logger.info({ confirmed: confirmedGaps.length, total: gapCandidates.length }, "[P6] Gap pass: visiting confirmed gap URLs");
2356
+ const gapLoginPattern = /\/(login|signin|sign-in|auth|log-in)(\/|$|\?)/i;
2357
+ for (const gap of confirmedGaps.slice(0, 8)) {
2358
+ const gapUrl = gap.confirmedUrl;
2359
+ if (visited.size >= maxScreens) break;
2360
+ try {
2361
+ await page.goto(gapUrl, { waitUntil: "domcontentloaded", timeout: 2e4 });
2362
+ if (gapLoginPattern.test(page.url())) {
2363
+ logger.warn({ gapUrl, landed: page.url() }, "[P6] Gap URL redirected to login \u2014 skipping");
2364
+ continue;
2365
+ }
2366
+ await page.waitForTimeout(1500);
2367
+ const gapName = gap.category;
2368
+ const gapScreen = await AppBrain.saveScreen(opts.tenantId, opts.projectId, gapName, gapUrl, opts.jobId, "ENTRY_POINT");
2369
+ const gapShot = `${opts.screenshotDir ?? "/tmp"}/${gapScreen.id}-v${gapScreen.version}.png`;
2370
+ await waitForScreenshotReady(page);
2371
+ await page.screenshot({ path: gapShot, fullPage: true });
2372
+ await AppBrain.saveScreenshot(opts.tenantId, opts.projectId, gapScreen.id, gapShot, gapScreen.version, "DESKTOP");
2373
+ const [gapElems, gapLinks, gapLinkMaps] = await Promise.all([
2374
+ extractElements(stagehand, page, elementLLM ?? void 0).catch(() => []),
2375
+ discoverLinks(stagehand, origin, page, linkLLM ?? void 0, false).catch(() => []),
2376
+ extractLinkMappings(page, origin).catch(() => [])
2377
+ ]);
2378
+ for (const el of gapElems) {
2379
+ await AppBrain.saveElement(opts.tenantId, opts.projectId, gapScreen.id, el.meaning, el.role, el.expectedData, el.notes, void 0, true, el.boundingRect, el.ariaState, el.parentLandmark).catch(() => {
2380
+ });
2381
+ }
2382
+ visited.set(canonicalize(gapUrl), gapScreen.id);
2383
+ discovered.push({ url: gapUrl, screenId: gapScreen.id, elements: gapElems.length, version: gapScreen.version });
2384
+ for (const lm of gapLinkMaps) {
2385
+ pendingTransitions.push({ fromScreenId: gapScreen.id, elementMeaning: lm.label, targetUrl: lm.href });
2386
+ }
2387
+ for (const href of gapLinks) {
2388
+ const canon = canonicalize(href);
2389
+ if (isUsefulNavigationUrl(href, origin) && !queued.has(canon) && !visited.has(canon) && queued.size < maxScreens * 2) {
2390
+ queued.add(canon);
2391
+ queue.push(href);
2392
+ }
2393
+ }
2394
+ logger.info({ url: gapUrl, elements: gapElems.length, category: gap.category }, "[P6] Gap URL captured");
2395
+ } catch (err) {
2396
+ logger.warn({ url: gapUrl, err: String(err) }, "[P6] Gap URL visit failed \u2014 non-fatal");
2397
+ }
2398
+ }
2399
+ } else {
2400
+ logger.info({ total: gapCandidates.length }, "[P6] Gap pass: no confirmed gaps found");
2401
+ }
2402
+ } catch (err) {
2403
+ logger.warn({ err: String(err) }, "[P6] Gap pass failed \u2014 non-fatal, continuing");
2404
+ }
2405
+ }
2406
+ if (!selective && visited.size > 0) {
2407
+ const discoveredUrls = Array.from(visited.keys());
2408
+ await AppBrain.markRemovedScreens(opts.tenantId, opts.projectId, discoveredUrls, opts.jobId);
2409
+ }
2410
+ await onProgress?.(82, "Saving app map");
2411
+ for (const edge of edges) {
2412
+ const fromId = visited.get(canonicalize(edge.fromUrl));
2413
+ const toId = visited.get(canonicalize(edge.toUrl));
2414
+ if (fromId && toId && fromId !== toId) {
2415
+ await AppBrain.saveFlow(opts.tenantId, opts.projectId, `${edge.fromUrl} -> ${edge.toUrl}`, fromId, toId);
2416
+ }
2417
+ }
2418
+ await onProgress?.(84, "Wiring navigation graph (KAG)");
2419
+ const visitedByPath = /* @__PURE__ */ new Map();
2420
+ for (const [canonUrl] of visited) {
2421
+ try {
2422
+ const p = new URL(canonUrl).pathname;
2423
+ const arr = visitedByPath.get(p);
2424
+ if (arr) arr.push(canonUrl);
2425
+ else visitedByPath.set(p, [canonUrl]);
2426
+ } catch {
2427
+ }
2428
+ }
2429
+ for (const pt of pendingTransitions) {
2430
+ let toScreenId = visited.get(canonicalize(pt.targetUrl));
2431
+ if (!toScreenId) {
2432
+ try {
2433
+ const targetCanon = new URL(canonicalize(pt.targetUrl));
2434
+ const candidates = visitedByPath.get(targetCanon.pathname) ?? [];
2435
+ for (const cand of candidates) {
2436
+ const candUrl = new URL(cand);
2437
+ let allMatch = true;
2438
+ for (const [k, v] of targetCanon.searchParams) {
2439
+ if (candUrl.searchParams.get(k) !== v) {
2440
+ allMatch = false;
2441
+ break;
2442
+ }
2443
+ }
2444
+ if (allMatch) {
2445
+ toScreenId = visited.get(cand);
2446
+ break;
2447
+ }
2448
+ }
2449
+ } catch {
2450
+ }
2451
+ }
2452
+ if (toScreenId && toScreenId !== pt.fromScreenId) {
2453
+ await AppBrain.setElementTransition(opts.tenantId, pt.fromScreenId, pt.elementMeaning, toScreenId).catch(() => {
2454
+ });
2455
+ await AppBrain.saveScreenTransition(opts.tenantId, opts.projectId, {
2456
+ fromScreenId: pt.fromScreenId,
2457
+ toScreenId,
2458
+ action: {
2459
+ type: "click",
2460
+ label: pt.elementMeaning,
2461
+ targetUrl: pt.targetUrl,
2462
+ source: "crawl-link-mapping"
2463
+ },
2464
+ confidence: 0.8,
2465
+ jobId: opts.jobId
2466
+ }).catch(() => {
2467
+ });
2468
+ }
2469
+ }
2470
+ const transitionCount = pendingTransitions.filter((pt) => {
2471
+ if (visited.get(canonicalize(pt.targetUrl))) return true;
2472
+ try {
2473
+ const tc = new URL(canonicalize(pt.targetUrl));
2474
+ return (visitedByPath.get(tc.pathname) ?? []).some((cand) => {
2475
+ const cu = new URL(cand);
2476
+ for (const [k, v] of tc.searchParams) {
2477
+ if (cu.searchParams.get(k) !== v) return false;
2478
+ }
2479
+ return true;
2480
+ });
2481
+ } catch {
2482
+ return false;
2483
+ }
2484
+ }).length;
2485
+ logger.info({ transitionCount }, "KAG: wired element\u2192screen transitions");
2486
+ AppBrain.saveCrawlEvidence(opts.tenantId, opts.projectId, {
2487
+ jobId: opts.jobId,
2488
+ type: "WORKFLOW_GRAPH_SUMMARY",
2489
+ payload: {
2490
+ transitionCount,
2491
+ screenCount: visited.size,
2492
+ profile: domainProfile.industry
2493
+ }
2494
+ }).catch(() => {
2495
+ });
2496
+ if (capturedApiEndpoints.length > 0) {
2497
+ logger.info({ count: capturedApiEndpoints.length }, "Captured unique API endpoint patterns");
2498
+ }
2499
+ if (visited.size >= 5) {
2500
+ await onProgress?.(90, `App map saved \xB7 ${visited.size} screens, ${edges.length} flows wired`);
2501
+ }
2502
+ let sessionAliveForViewports = true;
2503
+ try {
2504
+ await page.goto(opts.appUrl, { waitUntil: "domcontentloaded", timeout: 15e3 });
2505
+ const sessionCheckUrl = page.url();
2506
+ const loginKeywords = /login|signin|sign-in|\/auth[/?#]/i;
2507
+ if (loginKeywords.test(sessionCheckUrl)) {
2508
+ logger.warn({ landed: sessionCheckUrl }, "[P5] Session expired before viewport pass \u2014 attempting re-auth");
2509
+ let reauthed = false;
2510
+ if (opts.credentials) {
2511
+ reauthed = await loginWithStagehand(
2512
+ stagehand,
2513
+ opts.credentials,
2514
+ opts.appUrl,
2515
+ { apiKey: opts.captchaSolverApiKey, provider: opts.captchaSolverProvider },
2516
+ { apiKey: opts.mailslurpApiKey, inboxId: opts.mailslurpInboxId },
2517
+ void 0,
2518
+ { tenantId: opts.tenantId, projectId: opts.projectId, jobId: opts.jobId }
2519
+ ).catch(() => false);
2520
+ }
2521
+ if (reauthed) {
2522
+ logger.info("[P5] Re-auth succeeded \u2014 proceeding with viewport pass");
2523
+ } else {
2524
+ sessionAliveForViewports = false;
2525
+ logger.warn({ landed: sessionCheckUrl }, "[P5] Re-auth failed \u2014 skipping mobile/tablet screenshots");
2526
+ AppBrain.saveCrawlEvidence(opts.tenantId, opts.projectId, {
2527
+ jobId: opts.jobId,
2528
+ type: "AUTH_FAILURE",
2529
+ payload: {
2530
+ url: opts.appUrl,
2531
+ landed: sessionCheckUrl,
2532
+ reason: "Session expired before mobile/tablet viewport pass and re-auth failed. Re-capture the session in project settings."
2533
+ }
2534
+ }).catch(() => {
2535
+ });
2536
+ }
2537
+ } else {
2538
+ logger.info({ url: sessionCheckUrl }, "[P5] Session alive \u2014 proceeding with viewport pass");
2539
+ }
2540
+ } catch (err) {
2541
+ logger.warn({ err: String(err) }, "[P5] Session health check failed \u2014 skipping viewport pass to avoid bad screenshots");
2542
+ sessionAliveForViewports = false;
2543
+ }
2544
+ const extraProfiles = (opts.deviceProfiles ?? []).filter((p) => p !== "DESKTOP");
2545
+ if (sessionAliveForViewports && extraProfiles.length > 0 && discovered.length > 0) {
2546
+ const { devices, chromium: pwChromium } = await import("playwright");
2547
+ const DEVICE_MAP = {
2548
+ // Override viewport heights: Playwright's device descriptors use viewport-minus-browser-chrome
2549
+ // (659 for iPhone 15, ~980 for iPad Pro 11). Override to full logical screen dimensions so
2550
+ // screenshots match what users see on device and mobile-detection scripts pass.
2551
+ MOBILE_PORTRAIT: { ...devices["iPhone 15"], viewport: { width: 393, height: 852 } },
2552
+ TABLET: { ...devices["iPad Pro 11"], viewport: { width: 834, height: 1194 } }
2553
+ };
2554
+ let mainStorageState;
2555
+ try {
2556
+ mainStorageState = await playwrightBrowser.contexts()[0]?.storageState();
2557
+ } catch {
2558
+ }
2559
+ for (const profile of extraProfiles) {
2560
+ const deviceConfig = DEVICE_MAP[profile];
2561
+ if (!deviceConfig) continue;
2562
+ await onProgress?.(95, `Capturing ${profile} screenshots + elements\u2026`);
2563
+ let mobileBrowser = null;
2564
+ try {
2565
+ mobileBrowser = await pwChromium.launch({
2566
+ headless: true,
2567
+ executablePath,
2568
+ args: chromeArgs
2569
+ });
2570
+ const mobileCtx = await mobileBrowser.newContext({
2571
+ ...deviceConfig,
2572
+ ignoreHTTPSErrors: true,
2573
+ ...mainStorageState ? { storageState: mainStorageState } : {}
2574
+ });
2575
+ const mobilePage = await mobileCtx.newPage();
2576
+ for (const disc of discovered) {
2577
+ if (!disc.screenId) continue;
2578
+ try {
2579
+ await mobilePage.goto(disc.url, { waitUntil: "domcontentloaded", timeout: 2e4 });
2580
+ await mobilePage.waitForLoadState("networkidle", { timeout: 8e3 }).catch(() => {
2581
+ });
2582
+ await dismissOverlays(mobilePage);
2583
+ try {
2584
+ await mobilePage.addStyleTag({ content: "*, *::before, *::after { animation-duration: 0s !important; transition-duration: 0s !important; }" });
2585
+ } catch {
2586
+ }
2587
+ const shotPath = path.join(opts.screenshotDir ?? "/tmp", `${disc.screenId}-${profile.toLowerCase()}-v${disc.version}.png`);
2588
+ await waitForScreenshotReady(mobilePage);
2589
+ const [, vpElements] = await Promise.all([
2590
+ mobilePage.screenshot({ path: shotPath, fullPage: true }),
2591
+ domFallbackElements(mobilePage)
2592
+ ]);
2593
+ await AppBrain.saveScreenshot(opts.tenantId, opts.projectId, disc.screenId, shotPath, disc.version, profile);
2594
+ AppBrain.autoCreateVisualBaseline(opts.tenantId, opts.projectId, disc.screenId, profile, shotPath, gitInfo.branch, gitInfo.commit || void 0).catch(() => {
2595
+ });
2596
+ for (const el of vpElements) {
2597
+ AppBrain.saveElement(
2598
+ opts.tenantId,
2599
+ opts.projectId,
2600
+ disc.screenId,
2601
+ el.meaning,
2602
+ el.role,
2603
+ el.expectedData,
2604
+ el.notes,
2605
+ void 0,
2606
+ true,
2607
+ el.boundingRect,
2608
+ el.ariaState,
2609
+ el.parentLandmark,
2610
+ profile
2611
+ ).catch(() => {
2612
+ });
2613
+ }
2614
+ } catch (err) {
2615
+ console.warn(`[crawler] ${profile} pass failed for ${disc.url}:`, err);
2616
+ }
2617
+ }
2618
+ await mobilePage.close().catch(() => {
2619
+ });
2620
+ await mobileCtx.close().catch(() => {
2621
+ });
2622
+ } catch (profileErr) {
2623
+ console.warn(`[crawler] ${profile} browser launch failed:`, profileErr);
2624
+ } finally {
2625
+ await mobileBrowser?.close().catch(() => {
2626
+ });
2627
+ }
2628
+ }
2629
+ }
2630
+ const shouldRunInteractiveProbe = opts.interactive || discovered.filter((d) => d.screenId).length < 30;
2631
+ if (shouldRunInteractiveProbe) {
2632
+ await onProgress?.(88, "Running interactive probe (capturing real errors & modals)");
2633
+ const probeInputs = discovered.filter((d) => d.screenId).map((d) => ({ url: d.url, screenId: d.screenId, elements: d.elementFacts ?? [] }));
2634
+ await probeAllScreens(page, opts.tenantId, opts.projectId, probeInputs, onProgress);
2635
+ }
2636
+ await onProgress?.(97, "Running post-crawl analysis");
2637
+ const successScreens = discovered.filter((d) => !!d.screenId);
2638
+ ;
2639
+ (async () => {
2640
+ try {
2641
+ if (successScreens.length > 0) {
2642
+ const sitemapUrls2 = screensToSitemapUrls(successScreens.map((d) => ({ url: d.url })));
2643
+ const sitemapPath = path.join(opts.screenshotDir ?? "/tmp", "sitemap.xml");
2644
+ await writeSitemap(sitemapUrls2, sitemapPath);
2645
+ AppBrain.saveCrawlEvidence(opts.tenantId, opts.projectId, {
2646
+ jobId: opts.jobId,
2647
+ type: "SITEMAP_WRITTEN",
2648
+ payload: { path: sitemapPath, urlCount: sitemapUrls2.length }
2649
+ }).catch(() => {
2650
+ });
2651
+ }
2652
+ if (capturedApiEndpoints.length > 0) {
2653
+ const spec = generateOpenApiSpec(capturedApiEndpoints, { title: `${opts.projectId} API`, version: "1.0.0" });
2654
+ const yamlStr = specToYaml(spec);
2655
+ const specPath = path.join(opts.screenshotDir ?? "/tmp", "openapi.yaml");
2656
+ await fs.writeFile(specPath, yamlStr, "utf8");
2657
+ AppBrain.saveCrawlEvidence(opts.tenantId, opts.projectId, {
2658
+ jobId: opts.jobId,
2659
+ type: "OPENAPI_SPEC",
2660
+ payload: { path: specPath, endpointCount: capturedApiEndpoints.length }
2661
+ }).catch(() => {
2662
+ });
2663
+ }
2664
+ const deadStats = deadLinkTracker.stats();
2665
+ if (deadStats.total > 0) {
2666
+ AppBrain.saveCrawlEvidence(opts.tenantId, opts.projectId, {
2667
+ jobId: opts.jobId,
2668
+ type: "DEAD_LINKS",
2669
+ payload: { ...deadStats, links: deadLinkTracker.getDeadLinks() }
2670
+ }).catch(() => {
2671
+ });
2672
+ }
2673
+ const redirectStats = redirectRegistry.stats();
2674
+ if (redirectStats.withRedirects > 0) {
2675
+ AppBrain.saveCrawlEvidence(opts.tenantId, opts.projectId, {
2676
+ jobId: opts.jobId,
2677
+ type: "REDIRECT_CHAINS",
2678
+ payload: { ...redirectStats, problematic: redirectRegistry.getProblematic() }
2679
+ }).catch(() => {
2680
+ });
2681
+ }
2682
+ if (successScreens.length > 0) {
2683
+ const sampleScreens = successScreens.slice(0, 5).map((d) => ({
2684
+ id: d.screenId,
2685
+ url: d.url,
2686
+ name: d.url
2687
+ }));
2688
+ const playwrightScript = generatePlaywrightTest(sampleScreens);
2689
+ const testPath = path.join(opts.screenshotDir ?? "/tmp", "generated.spec.ts");
2690
+ await fs.writeFile(testPath, playwrightScript, "utf8");
2691
+ AppBrain.saveCrawlEvidence(opts.tenantId, opts.projectId, {
2692
+ jobId: opts.jobId,
2693
+ type: "PLAYWRIGHT_TEST_GENERATED",
2694
+ payload: { path: testPath, screenCount: sampleScreens.length }
2695
+ }).catch(() => {
2696
+ });
2697
+ }
2698
+ const webhookUrl = opts.webhookUrl;
2699
+ if (webhookUrl) {
2700
+ const notifier = new WebhookNotifier({ url: webhookUrl, secret: opts.webhookSecret });
2701
+ await notifier.notify("crawl.completed", opts.jobId ?? "", opts.projectId, opts.tenantId, {
2702
+ screensFound: successScreens.length,
2703
+ apiEndpoints: capturedApiEndpoints.length,
2704
+ deadLinks: deadLinkTracker.stats().total
2705
+ }).catch(() => {
2706
+ });
2707
+ }
2708
+ } catch {
2709
+ }
2710
+ })();
2711
+ await onProgress?.(98, "Finalizing");
2712
+ const pendingUrls = queue.filter((u) => !visited.has(canonicalize(u)));
2713
+ const hasMoreSegments = pendingUrls.length > 0;
2714
+ const screensSucceeded = discovered.filter((d) => !!d.screenId).length;
2715
+ const screensErrored = discovered.filter((d) => !!d.error && !d.screenId).length;
2716
+ return {
2717
+ screensFound: screensSucceeded,
2718
+ // successful captures only (not errors)
2719
+ screensErrored,
2720
+ // pages visited but failed to capture
2721
+ screensSkippedToNextSegment: pendingUrls.length,
2722
+ isPartial: hasMoreSegments,
2723
+ // true = more segments auto-queued
2724
+ discovered,
2725
+ apiEndpoints: capturedApiEndpoints,
2726
+ performanceMetrics,
2727
+ pendingUrls,
2728
+ // feed into next segment's resumeUrls
2729
+ segmentIndex: segIdx,
2730
+ // which segment this was
2731
+ hasMoreSegments,
2732
+ domHashes: Array.from(seenDomHashes)
2733
+ // carry forward to next segment to avoid re-screenshotting
2734
+ };
2735
+ } finally {
2736
+ if (stagehandPromptTokens > 0 || stagehandCompletionTokens > 0) {
2737
+ const totalTokens = stagehandPromptTokens + stagehandCompletionTokens;
2738
+ const shProvider = stagehandModel.includes("/") ? stagehandModel.split("/")[0] : configuredProvider || "openai";
2739
+ const shModel = stagehandModel.includes("/") ? stagehandModel.split("/").slice(1).join("/") : stagehandModel;
2740
+ const shUsage = {
2741
+ provider: shProvider,
2742
+ model: shModel,
2743
+ promptTokens: stagehandPromptTokens,
2744
+ completionTokens: stagehandCompletionTokens,
2745
+ cacheReadTokens: 0,
2746
+ cacheWriteTokens: 0,
2747
+ totalTokens
2748
+ };
2749
+ recordLLMCall({
2750
+ tenantId: opts.tenantId,
2751
+ projectId: opts.projectId,
2752
+ jobId: opts.jobId,
2753
+ agentType: "crawl_stagehand",
2754
+ label: "stagehand_browser_actions",
2755
+ usage: { ...shUsage, estimatedCostUsd: estimateCost(shUsage, `${shProvider}/${shModel}`) }
2756
+ }).catch(() => {
2757
+ });
2758
+ }
2759
+ await playwrightBrowser?.close().catch(() => {
2760
+ });
2761
+ await stagehand.close().catch(() => {
2762
+ });
2763
+ await chromeProc.close().catch(() => {
2764
+ });
2765
+ }
2766
+ }
2767
+ 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;
2768
+ async function extractElements(stagehand, page, llmCfg) {
2769
+ const domElements = await domFallbackElements(page);
2770
+ if (domElements.length >= 2) return domElements;
2771
+ const contentElements = await contentFallbackElements(page);
2772
+ if (contentElements.length > 0) return [...domElements, ...contentElements];
2773
+ try {
2774
+ const extracted = await Promise.race([
2775
+ stagehand.extract(
2776
+ 'Extract ONLY the meaningful form and action elements on this page \u2014 inputs, selects, textareas, and action buttons. For each: a clear human-readable purpose (e.g. "Email address input", "Submit password reset button"), its role (button/input/select/checkbox/textarea), and for inputs the expected data type (valid_email/password/phone/date/search_term/text). SKIP: navigation links, header/footer links, cookie banners, theme toggles (dark mode/light mode), social share buttons, version strings, decorative elements, any button inside a nav or header. Focus ONLY on main content forms and primary action buttons. Return at most 20 elements.',
2777
+ ElementSchema
2778
+ ),
2779
+ new Promise((resolve) => setTimeout(() => resolve(null), 3e4))
2780
+ ]);
2781
+ if (!extracted) return domElements;
2782
+ const filtered = extracted.elements.filter((e) => !ELEMENT_NOISE.test(e.meaning ?? ""));
2783
+ return filtered.slice(0, 20);
2784
+ } catch {
2785
+ return domElements;
2786
+ }
2787
+ }
2788
+ async function discoverLinks(stagehand, origin, page, llmCfg, skipAi = false) {
2789
+ const navExpanded = await expandNavMenus(page);
2790
+ const domLinks = await domFallbackLinks(page, origin);
2791
+ const interactiveLinks = await interactAndCollectLinks(page, origin);
2792
+ if (navExpanded) {
2793
+ page.keyboard?.press("Escape").catch(() => {
2794
+ });
2795
+ await page.evaluate(() => {
2796
+ document.querySelectorAll('[aria-expanded="true"],[data-state="open"],[data-headlessui-state="open"]').forEach((el) => {
2797
+ try {
2798
+ el.click();
2799
+ } catch {
2800
+ }
2801
+ });
2802
+ }).catch(() => {
2803
+ });
2804
+ }
2805
+ const allDomLinks = Array.from(/* @__PURE__ */ new Set([...domLinks, ...interactiveLinks]));
2806
+ if (allDomLinks.length >= 30 || skipAi) return allDomLinks;
2807
+ try {
2808
+ const extracted = await Promise.race([
2809
+ stagehand.extract(
2810
+ `Find all in-app navigation links for this application (origin: ${origin}). Include: main nav, sidebar, content links, feature pages, settings pages. Exclude: logout, sign out, delete, external sites, anchor-only links (#), mailto:, javascript:.`,
2811
+ LinksSchema
2812
+ ),
2813
+ new Promise((resolve) => setTimeout(() => resolve(null), 12e3))
2814
+ ]);
2815
+ if (!extracted) return allDomLinks;
2816
+ const { links } = extracted;
2817
+ const currentUrl = page.url?.() ?? "";
2818
+ const aiLinks = links.map((href) => {
2819
+ try {
2820
+ const u = new URL(href, currentUrl);
2821
+ if (!/^#!?\//.test(u.hash)) u.hash = "";
2822
+ return u.toString();
2823
+ } catch {
2824
+ return null;
2825
+ }
2826
+ }).filter((u) => {
2827
+ if (!u) return false;
2828
+ return isUsefulNavigationUrl(u, origin);
2829
+ });
2830
+ return Array.from(/* @__PURE__ */ new Set([...allDomLinks, ...aiLinks])).filter((href) => isUsefulNavigationUrl(href, origin));
2831
+ } catch (err) {
2832
+ const is429 = err?.status === 429 || String(err?.message ?? "").includes("429") || String(err?.message ?? "").includes("rate limit");
2833
+ if (is429) throw Object.assign(new Error("RATE_LIMITED"), { isRateLimit: true, domLinks: allDomLinks });
2834
+ return allDomLinks;
2835
+ }
2836
+ }
2837
+ async function domFallbackElements(page) {
2838
+ return page.evaluate(() => {
2839
+ const out = [];
2840
+ const seen = /* @__PURE__ */ new Set();
2841
+ 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;
2842
+ const querySelectorAllDeep = (root, selector2) => {
2843
+ const results = Array.from(root.querySelectorAll(selector2));
2844
+ root.querySelectorAll("*").forEach((el) => {
2845
+ if (el.shadowRoot) {
2846
+ results.push(...querySelectorAllDeep(el.shadowRoot, selector2));
2847
+ }
2848
+ });
2849
+ return results;
2850
+ };
2851
+ 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"]';
2852
+ querySelectorAllDeep(document, selector).forEach((el) => {
2853
+ const style = window.getComputedStyle(el);
2854
+ if (style.display === "none" || style.visibility === "hidden" || style.opacity === "0" || style.pointerEvents === "none") return;
2855
+ if (el.hasAttribute("inert") || el.getAttribute("aria-disabled") === "true") return;
2856
+ const rect = el.getBoundingClientRect();
2857
+ if (rect.width === 0 && rect.height === 0) return;
2858
+ const tag = el.tagName.toLowerCase();
2859
+ const ariaRole = el.getAttribute("role");
2860
+ const isActionEl = tag === "button" || tag === "a" || ariaRole === "button";
2861
+ if (tag === "a" || ariaRole === "button" && tag !== "button") {
2862
+ let p = el.parentElement;
2863
+ while (p) {
2864
+ const ptag = p.tagName?.toLowerCase();
2865
+ if (["header", "footer"].includes(ptag)) return;
2866
+ p = p.parentElement;
2867
+ }
2868
+ }
2869
+ let labelText = "";
2870
+ if (el.id) {
2871
+ const linkedLabel = document.querySelector(`label[for="${el.id}"]`);
2872
+ if (linkedLabel) labelText = linkedLabel.textContent?.trim() || "";
2873
+ }
2874
+ if (!labelText) {
2875
+ const wrappingLabel = el.closest("label");
2876
+ if (wrappingLabel) {
2877
+ const clone = wrappingLabel.cloneNode(true);
2878
+ clone.querySelectorAll("input,select,textarea,button").forEach((c) => c.remove());
2879
+ labelText = clone.textContent?.replace(/\s+/g, " ").trim() || "";
2880
+ }
2881
+ }
2882
+ const label = (el.getAttribute("aria-label") || el.getAttribute("placeholder") || labelText || el.innerText?.replace(/\s+/g, " ").trim() || el.getAttribute("name") || "").slice(0, 80);
2883
+ if (!label || noise.test(label) || label.length > 70) return;
2884
+ const role = tag === "input" ? el.type || "text" : el.hasAttribute("contenteditable") ? "rich-text" : ariaRole || tag;
2885
+ const key = `${role}:${label.toLowerCase()}`;
2886
+ if (seen.has(key)) return;
2887
+ seen.add(key);
2888
+ let expectedData;
2889
+ if (tag === "input") {
2890
+ if (el.type === "email" || /email/i.test(label)) expectedData = "valid_email";
2891
+ else if (el.type === "password" || /password/i.test(label)) expectedData = "password";
2892
+ else if (el.type === "tel" || /phone|mobile/i.test(label)) expectedData = "phone";
2893
+ else if (el.type === "date" || /\bdate\b/i.test(label)) expectedData = "date";
2894
+ else if (/\b(code|otp|token)\b/i.test(label)) expectedData = "one_time_code";
2895
+ else if (/\b(search|query)\b/i.test(label)) expectedData = "search_term";
2896
+ else if (el.type === "number" || /\b(amount|quantity|count|age|year)\b/i.test(label)) expectedData = "number";
2897
+ else if (el.type === "url" || /\b(url|website|link)\b/i.test(label)) expectedData = "url";
2898
+ else if (el.type === "file") expectedData = "file";
2899
+ }
2900
+ const attrs = {};
2901
+ if (el.id) attrs.id = el.id;
2902
+ if (el.name) attrs.name = el.name;
2903
+ if (el.getAttribute("data-testid")) attrs["data-testid"] = el.getAttribute("data-testid");
2904
+ if (tag === "input" || tag === "textarea") {
2905
+ if (el.required) attrs.required = "true";
2906
+ if (el.pattern) attrs.pattern = el.pattern;
2907
+ if (el.minLength > 0) attrs.minLength = String(el.minLength);
2908
+ if (el.maxLength > 0 && el.maxLength < 524288) attrs.maxLength = String(el.maxLength);
2909
+ if (el.min !== "") attrs.min = String(el.min);
2910
+ if (el.max !== "") attrs.max = String(el.max);
2911
+ if (el.step && el.step !== "any") attrs.step = String(el.step);
2912
+ if (el.accept) attrs.accept = el.accept;
2913
+ if (el.multiple) attrs.multiple = "true";
2914
+ if (el.readOnly) attrs.readOnly = "true";
2915
+ if (el.disabled) attrs.disabled = "true";
2916
+ if (el.autocomplete && el.autocomplete !== "on") attrs.autocomplete = el.autocomplete;
2917
+ }
2918
+ const cssClass = (el.className || "").split(" ").map((c) => c.trim()).find((c) => c.length > 2 && !/^(true|false|active|disabled|hidden|visible)$/i.test(c));
2919
+ if (cssClass) attrs.cssClass = cssClass;
2920
+ if (tag === "select" && el.options?.length > 0 && el.options.length <= 60) {
2921
+ const opts = Array.from(el.options).map((o) => {
2922
+ const entry = { v: o.value, l: o.text.trim() };
2923
+ if (o.selected) entry.s = true;
2924
+ return entry;
2925
+ }).filter((o) => o.l);
2926
+ if (opts.length > 0) attrs.selectOptions = JSON.stringify(opts);
2927
+ }
2928
+ const describedById = el.getAttribute("aria-describedby");
2929
+ if (describedById) {
2930
+ const helpEl = document.getElementById(describedById.split(" ")[0]);
2931
+ const helpText = helpEl?.textContent?.trim().slice(0, 200);
2932
+ if (helpText) attrs.helpText = helpText;
2933
+ }
2934
+ const form = el.closest("form");
2935
+ if (form) {
2936
+ if (form.id) attrs.formId = form.id;
2937
+ const formAction = form.getAttribute("action");
2938
+ if (formAction && !formAction.startsWith("javascript:")) attrs.formAction = formAction.slice(0, 120);
2939
+ const formMethod = form.method;
2940
+ if (formMethod) attrs.formMethod = formMethod.toUpperCase();
2941
+ }
2942
+ const fieldset = el.closest("fieldset");
2943
+ if (fieldset) {
2944
+ const legendText = fieldset.querySelector("legend")?.textContent?.trim().slice(0, 60);
2945
+ if (legendText) attrs.fieldsetGroup = legendText;
2946
+ }
2947
+ if (tag === "button" || ariaRole === "button") {
2948
+ if (el.type && el.type !== "button") attrs.buttonType = el.type;
2949
+ if (/\b(delete|remove|destroy|revoke|clear|reset|discard|cancel)\b/i.test(label)) attrs.isDestructive = "true";
2950
+ }
2951
+ const stateMap = {};
2952
+ const ariaExpanded = el.getAttribute("aria-expanded");
2953
+ const ariaChecked = el.getAttribute("aria-checked");
2954
+ const ariaPressed = el.getAttribute("aria-pressed");
2955
+ const ariaSelected = el.getAttribute("aria-selected");
2956
+ const ariaInvalid = el.getAttribute("aria-invalid");
2957
+ const ariaCurrent = el.getAttribute("aria-current");
2958
+ if (ariaExpanded !== null) stateMap.expanded = ariaExpanded;
2959
+ if (ariaChecked !== null) stateMap.checked = ariaChecked;
2960
+ if (ariaPressed !== null) stateMap.pressed = ariaPressed;
2961
+ if (ariaSelected !== null) stateMap.selected = ariaSelected;
2962
+ if (ariaInvalid && ariaInvalid !== "false") stateMap.invalid = ariaInvalid;
2963
+ if (ariaCurrent) stateMap.current = ariaCurrent;
2964
+ if (el.getAttribute("aria-required") === "true" && !attrs.required) stateMap.required = "true";
2965
+ const getRegion = (node) => {
2966
+ let p = node.parentElement;
2967
+ while (p && p !== document.body) {
2968
+ const ptag = p.tagName?.toLowerCase();
2969
+ const prole = p.getAttribute?.("role");
2970
+ if (prole === "dialog" || prole === "alertdialog") return "modal";
2971
+ if (prole === "main" || ptag === "main") return "main";
2972
+ if (prole === "complementary" || ptag === "aside") return "aside";
2973
+ if (prole === "navigation" || ptag === "nav") return "nav";
2974
+ if (prole === "banner" || ptag === "header") return "header";
2975
+ if (prole === "contentinfo" || ptag === "footer") return "footer";
2976
+ p = p.parentElement;
2977
+ }
2978
+ return void 0;
2979
+ };
2980
+ const parentLandmark = getRegion(el);
2981
+ if (el.hasAttribute("contenteditable")) {
2982
+ const cls = ((el.className || "") + " " + (el.getAttribute("data-type") || "")).toLowerCase();
2983
+ const editorType = cls.includes("tiptap") || cls.includes("prosemirror") ? "tiptap" : cls.includes("cm-") || cls.includes("codemirror") ? "codemirror" : window.Quill ? "quill" : typeof window.monaco !== "undefined" ? "monaco" : "contenteditable";
2984
+ attrs.editorType = editorType;
2985
+ }
2986
+ const notes = Object.keys(attrs).length > 0 ? JSON.stringify(attrs) : void 0;
2987
+ const boundingRect = {
2988
+ x: Math.round(rect.x),
2989
+ y: Math.round(rect.y),
2990
+ width: Math.round(rect.width),
2991
+ height: Math.round(rect.height),
2992
+ top: Math.round(rect.top),
2993
+ left: Math.round(rect.left),
2994
+ right: Math.round(rect.right),
2995
+ bottom: Math.round(rect.bottom)
2996
+ };
2997
+ out.push({
2998
+ meaning: label,
2999
+ role,
3000
+ expectedData,
3001
+ notes,
3002
+ boundingRect,
3003
+ ariaState: Object.keys(stateMap).length > 0 ? stateMap : void 0,
3004
+ parentLandmark: parentLandmark ?? void 0
3005
+ });
3006
+ });
3007
+ const priority = (e) => ["email", "password", "text", "tel", "date", "number", "select", "textarea", "url", "file"].includes(e.role) ? 0 : 1;
3008
+ out.sort((a, b) => priority(a) - priority(b));
3009
+ return out;
3010
+ }).catch(() => []);
3011
+ }
3012
+ async function contentFallbackElements(page) {
3013
+ return page.evaluate(() => {
3014
+ const out = [];
3015
+ const seen = /* @__PURE__ */ new Set();
3016
+ const addUniq = (item) => {
3017
+ const key = `${item.role}:${item.meaning.toLowerCase().slice(0, 60)}`;
3018
+ if (!seen.has(key) && item.meaning.trim().length > 0) {
3019
+ seen.add(key);
3020
+ out.push(item);
3021
+ }
3022
+ };
3023
+ const title = document.title?.trim();
3024
+ if (title) addUniq({ meaning: title, role: "page-title", notes: JSON.stringify({ tag: "title" }) });
3025
+ const desc = document.querySelector('meta[name="description"]')?.getAttribute("content")?.trim();
3026
+ if (desc) addUniq({ meaning: desc, role: "meta-description", notes: JSON.stringify({ tag: "meta[name=description]" }) });
3027
+ const ogTitle = document.querySelector('meta[property="og:title"]')?.getAttribute("content")?.trim();
3028
+ if (ogTitle) addUniq({ meaning: ogTitle, role: "og-title", notes: JSON.stringify({ tag: "meta[property=og:title]" }) });
3029
+ const canonical = document.querySelector('link[rel="canonical"]')?.getAttribute("href")?.trim();
3030
+ if (canonical) addUniq({ meaning: canonical, role: "canonical-url", notes: JSON.stringify({ tag: "link[rel=canonical]" }) });
3031
+ document.querySelectorAll("h1, h2, h3").forEach((h) => {
3032
+ const text = h.textContent?.replace(/\s+/g, " ").trim().slice(0, 80);
3033
+ if (text) addUniq({ meaning: text, role: h.tagName.toLowerCase(), notes: JSON.stringify({ level: h.tagName }) });
3034
+ });
3035
+ document.querySelectorAll("img[alt]").forEach((img) => {
3036
+ const alt = img.getAttribute("alt")?.trim().slice(0, 80);
3037
+ const src = img.getAttribute("src")?.slice(0, 120);
3038
+ if (alt) addUniq({ meaning: alt, role: "image", notes: JSON.stringify({ src }) });
3039
+ });
3040
+ const navTags = /* @__PURE__ */ new Set(["nav", "header", "footer"]);
3041
+ document.querySelectorAll("a[href]").forEach((a) => {
3042
+ let p = a.parentElement;
3043
+ while (p) {
3044
+ if (navTags.has(p.tagName?.toLowerCase())) return;
3045
+ p = p.parentElement;
3046
+ }
3047
+ const label = (a.getAttribute("aria-label") || a.textContent?.replace(/\s+/g, " ").trim() || "").slice(0, 80);
3048
+ const href = a.href;
3049
+ if (label && href && !/^(#|javascript:|mailto:)/i.test(href)) {
3050
+ const isExternal = !href.includes(location.hostname);
3051
+ addUniq({ meaning: label, role: isExternal ? "external-link" : "internal-link", notes: JSON.stringify({ href: href.slice(0, 120) }) });
3052
+ }
3053
+ });
3054
+ const hasAnimation = !!document.querySelector('[class*="animate"], [class*="motion"], [class*="transition"], [class*="scroll-"], [data-aos], [data-scroll]');
3055
+ if (hasAnimation) addUniq({ meaning: "CSS animations / scroll effects detected on page", role: "animation-layer", notes: JSON.stringify({ detected: true }) });
3056
+ if (document.querySelector("video")) addUniq({ meaning: "Video element present", role: "video", notes: JSON.stringify({ count: document.querySelectorAll("video").length }) });
3057
+ if (document.querySelector("canvas")) addUniq({ meaning: "Canvas element present", role: "canvas" });
3058
+ return out.slice(0, 30);
3059
+ }).catch(() => []);
3060
+ }
3061
+ async function interactAndCollectLinks(page, origin) {
3062
+ const collected = /* @__PURE__ */ new Set();
3063
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
3064
+ const collectCurrentLinks = async () => {
3065
+ const links = await page.evaluate((origin2) => {
3066
+ const out = [];
3067
+ const logout = /logout|log out|sign out|delete/i;
3068
+ document.querySelectorAll("a[href]").forEach((a) => {
3069
+ if (logout.test(a.textContent ?? "")) return;
3070
+ try {
3071
+ const u = new URL(a.href);
3072
+ if (u.origin === origin2) {
3073
+ if (u.hash && !/^#!?\//.test(u.hash)) u.hash = "";
3074
+ out.push(u.toString());
3075
+ }
3076
+ } catch {
3077
+ }
3078
+ });
3079
+ return out;
3080
+ }, origin).catch(() => []);
3081
+ links.forEach((l) => collected.add(l));
3082
+ };
3083
+ try {
3084
+ const tabCount = await page.evaluate(
3085
+ () => document.querySelectorAll('[role="tab"]').length
3086
+ ).catch(() => 0);
3087
+ for (let i = 0; i < Math.min(tabCount, 15); i++) {
3088
+ try {
3089
+ const clicked = await page.evaluate((idx) => {
3090
+ const tabs = document.querySelectorAll('[role="tab"]');
3091
+ const tab = tabs[idx];
3092
+ if (!tab) return false;
3093
+ const s = window.getComputedStyle(tab);
3094
+ const r = tab.getBoundingClientRect();
3095
+ if (s.display === "none" || r.width < 4 || r.height < 4) return false;
3096
+ tab.click();
3097
+ return true;
3098
+ }, i).catch(() => false);
3099
+ if (clicked) {
3100
+ await sleep(350);
3101
+ await collectCurrentLinks();
3102
+ }
3103
+ } catch {
3104
+ }
3105
+ }
3106
+ const carouselNextSel = [
3107
+ 'button[aria-label*="next" i]',
3108
+ 'button[aria-label*="forward" i]',
3109
+ 'button[aria-label*="right" i]',
3110
+ '[data-direction="next"]',
3111
+ '[class*="carousel" i] button[class*="next" i]',
3112
+ '[class*="slider" i] button[class*="next" i]'
3113
+ ].join(", ");
3114
+ for (let i = 0; i < 8; i++) {
3115
+ const advanced = await page.evaluate((sel) => {
3116
+ const btn = document.querySelector(sel);
3117
+ if (!btn) return false;
3118
+ const s = window.getComputedStyle(btn);
3119
+ const r = btn.getBoundingClientRect();
3120
+ if (s.display === "none" || r.width < 4 || r.height < 4) return false;
3121
+ btn.click();
3122
+ return true;
3123
+ }, carouselNextSel).catch(() => false);
3124
+ if (!advanced) break;
3125
+ await sleep(300);
3126
+ await collectCurrentLinks();
3127
+ }
3128
+ const accordionCount = await page.evaluate(
3129
+ () => document.querySelectorAll('details:not([open]) > summary, [data-state="closed"] > button, [aria-expanded="false"][class*="accordion" i]').length
3130
+ ).catch(() => 0);
3131
+ for (let i = 0; i < Math.min(accordionCount, 10); i++) {
3132
+ try {
3133
+ await page.evaluate((idx) => {
3134
+ const items = document.querySelectorAll(
3135
+ 'details:not([open]) > summary, [data-state="closed"] > button, [aria-expanded="false"][class*="accordion" i]'
3136
+ );
3137
+ const el = items[idx];
3138
+ if (el) {
3139
+ try {
3140
+ el.click();
3141
+ } catch {
3142
+ }
3143
+ }
3144
+ }, i).catch(() => {
3145
+ });
3146
+ await sleep(250);
3147
+ await collectCurrentLinks();
3148
+ } catch {
3149
+ }
3150
+ }
3151
+ } catch {
3152
+ }
3153
+ return Array.from(collected);
3154
+ }
3155
+ async function expandNavMenus(page) {
3156
+ try {
3157
+ const clicked = await page.evaluate(() => {
3158
+ const isVisible = (el) => {
3159
+ const s = window.getComputedStyle(el);
3160
+ if (s.display === "none" || s.visibility === "hidden" || parseFloat(s.opacity) < 0.1) return false;
3161
+ const r = el.getBoundingClientRect();
3162
+ return r.width > 4 && r.height > 4;
3163
+ };
3164
+ const inBadContext = (el) => {
3165
+ let p = el.parentElement;
3166
+ while (p) {
3167
+ const tag = p.tagName?.toLowerCase();
3168
+ const role = p.getAttribute("role") ?? "";
3169
+ if (["form", "table", "select"].includes(tag ?? "") || ["dialog", "alertdialog", "combobox", "listbox", "grid", "treegrid"].includes(role)) return true;
3170
+ p = p.parentElement;
3171
+ }
3172
+ return false;
3173
+ };
3174
+ const targets = /* @__PURE__ */ new Set();
3175
+ document.querySelectorAll('[aria-haspopup]:not([aria-expanded="true"])').forEach((el) => {
3176
+ if (isVisible(el) && !inBadContext(el)) targets.add(el);
3177
+ });
3178
+ document.querySelectorAll('[aria-expanded="false"]').forEach((el) => {
3179
+ if ((el.tagName === "BUTTON" || el.getAttribute("role") === "button") && isVisible(el) && !inBadContext(el)) targets.add(el);
3180
+ });
3181
+ document.querySelectorAll('[data-state="closed"],[data-headlessui-state="closed"]').forEach((el) => {
3182
+ if ((el.tagName === "BUTTON" || el.getAttribute("role") === "button") && isVisible(el) && !inBadContext(el)) targets.add(el);
3183
+ });
3184
+ let count = 0;
3185
+ targets.forEach((el) => {
3186
+ try {
3187
+ el.click();
3188
+ count++;
3189
+ } catch {
3190
+ }
3191
+ });
3192
+ return count;
3193
+ });
3194
+ if (clicked > 0) await new Promise((r) => setTimeout(r, 500));
3195
+ try {
3196
+ const navHandles = await page.$$(
3197
+ 'nav > ul > li, nav > li, header > ul > li, header > nav > ul > li, [role="navigation"] > ul > li, [role="menubar"] > [role="menuitem"], [class*="nav"] > ul > li, [class*="menu"] > li'
3198
+ ).catch(() => []);
3199
+ for (const h of navHandles.slice(0, 20)) {
3200
+ try {
3201
+ await h.hover({ force: true, timeout: 600 });
3202
+ await page.waitForTimeout(280);
3203
+ } catch {
3204
+ }
3205
+ }
3206
+ if (navHandles.length > 0) {
3207
+ await page.mouse.move(2, 2);
3208
+ await page.waitForTimeout(150);
3209
+ }
3210
+ } catch {
3211
+ }
3212
+ return clicked > 0;
3213
+ } catch {
3214
+ return false;
3215
+ }
3216
+ }
3217
+ async function domFallbackLinks(page, origin) {
3218
+ return page.evaluate((origin2) => {
3219
+ const logout = /logout|log out|sign out|delete/i;
3220
+ const isRouteHash = (hash) => /^#!?\//.test(hash);
3221
+ const seen = /* @__PURE__ */ new Set();
3222
+ const add = (rawHref, textContent) => {
3223
+ if (logout.test(textContent)) return;
3224
+ if (!rawHref) return;
3225
+ if (rawHref.startsWith("#") && !isRouteHash(rawHref)) return;
3226
+ if (rawHref.startsWith("mailto:") || rawHref.startsWith("javascript:")) return;
3227
+ try {
3228
+ const u = new URL(rawHref, location.href);
3229
+ if (!isRouteHash(u.hash)) u.hash = "";
3230
+ if (new URL(u).origin === origin2) seen.add(u.toString());
3231
+ } catch {
3232
+ }
3233
+ };
3234
+ const allRoots = [document];
3235
+ const rootSeen = /* @__PURE__ */ new WeakSet();
3236
+ rootSeen.add(document);
3237
+ while (allRoots.length > 0) {
3238
+ const root = allRoots.shift();
3239
+ if (!root) continue;
3240
+ try {
3241
+ root.querySelectorAll("a[href]").forEach((a) => add(a.href || a.getAttribute("href") || "", a.textContent || ""));
3242
+ root.querySelectorAll("[href]:not(a):not(link):not(base):not(script)").forEach((el) => {
3243
+ add(el.getAttribute("href") || "", el.textContent || "");
3244
+ });
3245
+ root.querySelectorAll("[data-href]").forEach((el) => {
3246
+ add(el.getAttribute("data-href") || "", el.textContent || "");
3247
+ });
3248
+ root.querySelectorAll("*").forEach((el) => {
3249
+ try {
3250
+ if (el.shadowRoot && !rootSeen.has(el.shadowRoot)) {
3251
+ rootSeen.add(el.shadowRoot);
3252
+ allRoots.push(el.shadowRoot);
3253
+ }
3254
+ } catch (_) {
3255
+ }
3256
+ });
3257
+ } catch (_) {
3258
+ }
3259
+ }
3260
+ return Array.from(seen);
3261
+ }, origin).catch(() => []);
3262
+ }
3263
+ async function extractLinkMappings(page, origin) {
3264
+ return page.evaluate((origin2) => {
3265
+ const logout = /logout|log out|sign out|delete/i;
3266
+ return Array.from(document.querySelectorAll("a[href]")).filter((a) => !logout.test(a.textContent ?? "")).map((a) => {
3267
+ const label = (a.getAttribute("aria-label") || a.textContent?.replace(/\s+/g, " ").trim() || a.getAttribute("title") || "").slice(0, 80);
3268
+ const href = (() => {
3269
+ try {
3270
+ const u = new URL(a.href);
3271
+ if (!/^#!?\//.test(u.hash)) u.hash = "";
3272
+ return u.toString();
3273
+ } catch {
3274
+ return "";
3275
+ }
3276
+ })();
3277
+ return { label, href };
3278
+ }).filter(
3279
+ ({ label, href }) => label.length > 2 && href.length > 0 && (() => {
3280
+ try {
3281
+ return new URL(href).origin === origin2;
3282
+ } catch {
3283
+ return false;
3284
+ }
3285
+ })()
3286
+ );
3287
+ }, origin).catch(() => []);
3288
+ }
3289
+ async function computeDomHash(page) {
3290
+ return page.evaluate(() => {
3291
+ let hashStr = "";
3292
+ const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_ELEMENT);
3293
+ let node;
3294
+ while (node = walker.nextNode()) {
3295
+ const el = node;
3296
+ if (/^(MAIN|SECTION|NAV|FORM|INPUT|SELECT|TEXTAREA|BUTTON|TABLE|TR|TD|TH|UL|LI|A|H1|H2|H3)$/.test(el.tagName)) {
3297
+ let depth = 0;
3298
+ let parent = el.parentElement;
3299
+ while (parent) {
3300
+ if (/^(MAIN|SECTION|NAV|FORM|INPUT|SELECT|TEXTAREA|BUTTON|TABLE|TR|TD|TH|UL|LI|A|H1|H2|H3)$/.test(parent.tagName)) depth++;
3301
+ parent = parent.parentElement;
3302
+ }
3303
+ hashStr += el.tagName + depth + "|";
3304
+ }
3305
+ }
3306
+ let hash = 5381;
3307
+ for (let i = 0; i < hashStr.length; i++) {
3308
+ hash = hash * 33 ^ hashStr.charCodeAt(i);
3309
+ }
3310
+ return (hash >>> 0).toString(16);
3311
+ }).catch(() => "");
3312
+ }
3313
+ async function waitForURLChange(page, fromUrl, timeout = 1e4) {
3314
+ const deadline = Date.now() + timeout;
3315
+ while (Date.now() < deadline) {
3316
+ const current = page.url();
3317
+ try {
3318
+ const from = new URL(fromUrl);
3319
+ const now = new URL(current);
3320
+ if (now.pathname !== from.pathname || now.origin !== from.origin) return;
3321
+ } catch {
3322
+ if (current !== fromUrl) return;
3323
+ }
3324
+ await new Promise((r) => setTimeout(r, 200));
3325
+ }
3326
+ }
3327
+ async function dismissOverlays(page) {
3328
+ const selectors = [
3329
+ // OneTrust / CookieBot / TrustArc (most common GDPR banners)
3330
+ "#onetrust-accept-btn-handler",
3331
+ "#accept-cookie-consent",
3332
+ ".cc-btn.cc-allow",
3333
+ "[data-cookiebanner] button",
3334
+ '[id*="cookie-accept"]',
3335
+ '[class*="cookie-accept"]',
3336
+ '[id*="cookieAccept"]',
3337
+ '[class*="cookieAccept"]',
3338
+ 'button[aria-label*="Accept cookies" i]',
3339
+ 'button[aria-label*="Accept all" i]',
3340
+ 'button[title*="Accept" i][class*="cookie" i]',
3341
+ // Intercom / Drift / HubSpot chat
3342
+ '[class*="intercom-close"]',
3343
+ '#hubspot-messages-iframe-container [aria-label*="close" i]',
3344
+ // Generic overlay close buttons
3345
+ '[role="dialog"] button[aria-label="Close" i]',
3346
+ '[role="dialog"] button[aria-label="Dismiss" i]',
3347
+ // Newsletter popups
3348
+ 'button[class*="modal-close"]',
3349
+ 'button[class*="popup-close"]',
3350
+ '[class*="newsletter"] button[class*="close"]'
3351
+ ];
3352
+ for (const sel of selectors) {
3353
+ try {
3354
+ const el = page.locator(sel).first();
3355
+ if (await el.isVisible({ timeout: 300 }).catch(() => false)) {
3356
+ await el.click({ timeout: 500, force: true }).catch(() => {
3357
+ });
3358
+ }
3359
+ } catch {
3360
+ }
3361
+ }
3362
+ await page.waitForTimeout(300).catch(() => {
3363
+ });
3364
+ }
3365
+ async function scrollToTriggerLazyLoad(page) {
3366
+ try {
3367
+ await page.evaluate(async () => {
3368
+ const step = Math.max(window.innerHeight, 400);
3369
+ const total = Math.min(document.body.scrollHeight, 12e3);
3370
+ for (let y = step; y < total; y += step) {
3371
+ window.scrollTo(0, y);
3372
+ await new Promise((r) => setTimeout(r, 120));
3373
+ }
3374
+ window.scrollTo(0, 0);
3375
+ });
3376
+ await page.waitForTimeout(400).catch(() => {
3377
+ });
3378
+ await waitForStableDOM(page, 3e3, 300);
3379
+ } catch {
3380
+ }
3381
+ }
3382
+ async function waitForStableDOM(page, maxWait = 8e3, stabilityMs = 400) {
3383
+ const deadline = Date.now() + maxWait;
3384
+ let lastCount = -1;
3385
+ let stableAt = 0;
3386
+ while (Date.now() < deadline) {
3387
+ const count = await page.evaluate(() => document.querySelectorAll("*").length).catch(() => 0);
3388
+ if (count !== lastCount) {
3389
+ lastCount = count;
3390
+ stableAt = Date.now();
3391
+ } else if (Date.now() - stableAt >= stabilityMs) {
3392
+ return;
3393
+ }
3394
+ await new Promise((r) => setTimeout(r, 100));
3395
+ }
3396
+ }
3397
+ async function waitForScreenshotReady(page) {
3398
+ try {
3399
+ await page.waitForLoadState("domcontentloaded", { timeout: 5e3 }).catch(() => {
3400
+ });
3401
+ await page.waitForLoadState("networkidle", { timeout: 12e3 }).catch(() => {
3402
+ });
3403
+ await page.evaluate(() => {
3404
+ if (document.readyState === "complete") return true;
3405
+ return new Promise((resolve) => {
3406
+ const done = () => resolve(true);
3407
+ window.addEventListener("load", done, { once: true });
3408
+ setTimeout(done, 4e3);
3409
+ });
3410
+ }).catch(() => {
3411
+ });
3412
+ await page.evaluate(async () => {
3413
+ const fonts = document.fonts;
3414
+ if (fonts?.ready) await fonts.ready;
3415
+ }).catch(() => {
3416
+ });
3417
+ await scrollToTriggerLazyLoad(page);
3418
+ await waitForStableDOM(page, 1e4, 700);
3419
+ await page.evaluate(async () => {
3420
+ const pending = Array.from(document.images).filter((img) => !img.complete);
3421
+ if (pending.length === 0) return;
3422
+ await Promise.race([
3423
+ Promise.all(pending.map((img) => new Promise((resolve) => {
3424
+ img.addEventListener("load", () => resolve(), { once: true });
3425
+ img.addEventListener("error", () => resolve(), { once: true });
3426
+ }))),
3427
+ new Promise((resolve) => setTimeout(resolve, 4e3))
3428
+ ]);
3429
+ }).catch(() => {
3430
+ });
3431
+ await waitForStableDOM(page, 4e3, 500);
3432
+ await page.waitForTimeout(250).catch(() => {
3433
+ });
3434
+ } catch {
3435
+ }
3436
+ }
3437
+ async function hasMeaningfulAuthOrPublicState(page, requestedUrl) {
3438
+ const pathLooksRelevant = (() => {
3439
+ try {
3440
+ const pathname = new URL(page.url?.() ?? requestedUrl).pathname;
3441
+ return AUTH_PATH_RE.test(pathname) || PUBLIC_PATH_RE.test(pathname);
3442
+ } catch {
3443
+ return false;
3444
+ }
3445
+ })();
3446
+ const domLooksRelevant = await page.evaluate(() => {
3447
+ const text = (document.body?.innerText ?? "").toLowerCase();
3448
+ const inputs = Array.from(document.querySelectorAll("input"));
3449
+ const hasPassword = inputs.some((i) => i.type === "password");
3450
+ const hasIdentityInput = inputs.some((i) => {
3451
+ const meta = `${i.type} ${i.name} ${i.id} ${i.placeholder} ${i.autocomplete}`.toLowerCase();
3452
+ return /(email|username|user|login|phone|otp|code|password)/.test(meta);
3453
+ });
3454
+ 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);
3455
+ const hasPublicNav = /(pricing|contact|features|documentation|docs|about|privacy|terms)/i.test(text);
3456
+ return hasPassword || hasIdentityInput || hasAuthCopy || hasPublicNav;
3457
+ }).catch(() => false);
3458
+ return pathLooksRelevant || domLooksRelevant;
3459
+ }
3460
+ async function discoverAuthCandidates(page, origin) {
3461
+ return page.evaluate((siteOrigin) => {
3462
+ 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;
3463
+ const skipTextRe = /(logout|log out|sign out|delete|remove|discard|cancel subscription|payment|billing)/i;
3464
+ 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;
3465
+ const publicPathRe = /\/($|pricing|contact|about|help|docs|features|security|privacy|terms)(\/|$|\?)/i;
3466
+ const sameSiteBrowser = (href) => {
3467
+ try {
3468
+ const u = new URL(href, location.href);
3469
+ const s = new URL(siteOrigin);
3470
+ const strip = (h) => h.replace(/^www\./i, "");
3471
+ return strip(u.hostname) === strip(s.hostname);
3472
+ } catch {
3473
+ return false;
3474
+ }
3475
+ };
3476
+ const flowTypeFor = (value) => {
3477
+ const v = value.toLowerCase();
3478
+ if (/(forgot|reset|password)/.test(v)) return "password_reset";
3479
+ if (/(register|signup|sign up|create account|join)/.test(v)) return "register";
3480
+ if (/(verify|verification|otp|mfa|2fa|invite)/.test(v)) return v.includes("invite") ? "invite" : "verification";
3481
+ if (/(session-expired|expired|unauthorized|forbidden|401|403|logout|logged-out)/.test(v)) return "session_recovery";
3482
+ if (/(login|log in|signin|sign in|auth|sso|oauth)/.test(v)) return "login";
3483
+ return "public";
3484
+ };
3485
+ const textOf = (el) => (el.textContent || el.getAttribute("aria-label") || el.getAttribute("title") || el.getAttribute("placeholder") || "").replace(/\s+/g, " ").trim();
3486
+ const isVisible = (el) => {
3487
+ const rect = el.getBoundingClientRect?.();
3488
+ const style = window.getComputedStyle(el);
3489
+ return !!rect && rect.width > 0 && rect.height > 0 && style.display !== "none" && style.visibility !== "hidden" && style.opacity !== "0";
3490
+ };
3491
+ const links = /* @__PURE__ */ new Map();
3492
+ const addHref = (hrefRaw, labelRaw) => {
3493
+ if (!hrefRaw || /^(javascript|mailto|tel):/i.test(hrefRaw)) return;
3494
+ let href = "";
3495
+ try {
3496
+ href = new URL(hrefRaw, location.href).toString();
3497
+ } catch {
3498
+ return;
3499
+ }
3500
+ if (!sameSiteBrowser(href)) return;
3501
+ const u = new URL(href);
3502
+ const label = labelRaw || u.pathname;
3503
+ if (skipTextRe.test(label) || skipTextRe.test(u.pathname)) return;
3504
+ if (!authTextRe.test(label) && !authPathRe.test(u.pathname) && !publicPathRe.test(u.pathname)) return;
3505
+ const key = `${u.origin}${u.pathname.replace(/\/$/, "") || "/"}${u.search}`;
3506
+ if (!links.has(key)) links.set(key, { href, label, flowType: flowTypeFor(`${label} ${u.pathname}`) });
3507
+ };
3508
+ const walk = (root) => {
3509
+ root.querySelectorAll("a[href]").forEach((el) => addHref(el.href || el.getAttribute("href"), textOf(el)));
3510
+ root.querySelectorAll("[data-href],[data-url],[data-link]").forEach((el) => addHref(el.dataset?.href || el.dataset?.url || el.dataset?.link || "", textOf(el)));
3511
+ root.querySelectorAll("*").forEach((el) => {
3512
+ if (el.shadowRoot) walk(el.shadowRoot);
3513
+ });
3514
+ };
3515
+ walk(document);
3516
+ const clicks = [];
3517
+ const clickEls = Array.from(document.querySelectorAll('button,[role="button"],a:not([href]),[data-state],[aria-haspopup]'));
3518
+ let idx = 0;
3519
+ for (const el of clickEls) {
3520
+ if (clicks.length >= 12) break;
3521
+ if (!isVisible(el)) continue;
3522
+ const label = textOf(el);
3523
+ if (!label || !authTextRe.test(label) || skipTextRe.test(label)) continue;
3524
+ const type = (el.getAttribute("type") || "").toLowerCase();
3525
+ const inCredentialForm = !!el.closest("form")?.querySelector('input[type="password"],input[type="email"],input[name*="user" i],input[name*="email" i]');
3526
+ const submitLike = type === "submit" || /^(login|log in|sign in|continue)$/i.test(label);
3527
+ if (inCredentialForm && submitLike && !/(forgot|reset|register|sign up|create account|back to login|use email)/i.test(label)) continue;
3528
+ const attr = `zeta-auth-${idx++}`;
3529
+ el.setAttribute("data-zeta-auth-discovery-id", attr);
3530
+ clicks.push({ selector: `[data-zeta-auth-discovery-id="${attr}"]`, label, flowType: flowTypeFor(label) });
3531
+ }
3532
+ return { links: Array.from(links.values()).slice(0, 32), clicks };
3533
+ }, origin).catch(() => ({ links: [], clicks: [] }));
3534
+ }
3535
+ async function captureAuthDiscoveryScreen(page, opts, requestedUrl, source, ordinal, trigger) {
3536
+ const finalUrl = page.url?.() ?? requestedUrl;
3537
+ if (finalUrl.startsWith("chrome-error://")) return { captured: false, finalUrl, flowType: "public" };
3538
+ if (!await hasMeaningfulAuthOrPublicState(page, requestedUrl)) {
3539
+ return { captured: false, finalUrl, flowType: flowTypeForUrlOrText(`${requestedUrl} ${finalUrl} ${trigger?.label ?? ""}`) };
3540
+ }
3541
+ const flowType = trigger?.flowType ?? flowTypeForUrlOrText(`${requestedUrl} ${finalUrl} ${trigger?.label ?? ""}`);
3542
+ const title = await page.title().catch(() => "");
3543
+ const baseName = title?.trim() && !/^(untitled|about:blank)$/i.test(title.trim()) ? title.trim().slice(0, 72) : titleFromUrl(finalUrl);
3544
+ const prefix = flowType === "public" ? "Public" : `Auth ${flowType.replace("_", " ")}`;
3545
+ const name = `${prefix} \xB7 ${trigger?.label ? trigger.label.slice(0, 48) : baseName}`.replace(/\s+/g, " ");
3546
+ const screenUrl = trigger?.sameUrlState ? `${finalUrl.split("#")[0]}#zeta-auth-flow=${compactSlug(trigger.label ?? `${flowType}-${ordinal}`)}` : finalUrl;
3547
+ const shotPath = path.join(opts.screenshotDir ?? "/tmp", `auth-discovery-${opts.jobId ?? "run"}-${ordinal}.png`);
3548
+ await waitForScreenshotReady(page);
3549
+ await page.screenshot({ path: shotPath, fullPage: true }).catch(() => {
3550
+ });
3551
+ const screen = await AppBrain.saveScreen(opts.tenantId, opts.projectId, name, screenUrl, opts.jobId, source === "entry" ? "ENTRY_POINT" : "SEED_URL").catch(() => null);
3552
+ if (!screen) return { captured: false, finalUrl, flowType };
3553
+ await AppBrain.saveScreenshot(opts.tenantId, opts.projectId, screen.id, shotPath, screen.version, "DESKTOP").catch(() => {
3554
+ });
3555
+ await AppBrain.saveCrawlEvidence(opts.tenantId, opts.projectId, {
3556
+ screenId: screen.id,
3557
+ jobId: opts.jobId,
3558
+ type: "AUTH_DISCOVERY",
3559
+ payload: {
3560
+ requestedUrl,
3561
+ finalUrl,
3562
+ screenUrl,
3563
+ authState: flowType === "public" ? "logged_out" : "auth_flow",
3564
+ flowType,
3565
+ source,
3566
+ triggerLabel: trigger?.label ?? null
3567
+ }
3568
+ }).catch(() => {
3569
+ });
3570
+ logger.info({ requestedUrl, finalUrl, screenUrl, flowType, source }, "[crawler] auth-discovery: captured screen");
3571
+ return { captured: true, finalUrl, flowType };
3572
+ }
3573
+ async function runAuthDiscoveryPhase(page, opts, origin, onProgress) {
3574
+ const queue = [];
3575
+ const queued = /* @__PURE__ */ new Set();
3576
+ const capturedKeys = /* @__PURE__ */ new Set();
3577
+ const discoveredUrls = /* @__PURE__ */ new Set();
3578
+ const enqueue = (url, source) => {
3579
+ if (!shouldSeedAuthDiscoveryUrl(url, origin)) return;
3580
+ const key = normalizeAuthUrl(url);
3581
+ if (queued.has(key) || queue.length >= AUTH_DISCOVERY_MAX_QUEUE) return;
3582
+ queued.add(key);
3583
+ queue.push({ url, source });
3584
+ };
3585
+ enqueue(opts.appUrl, "entry");
3586
+ for (const p of AUTH_DISCOVERY_PATHS) {
3587
+ try {
3588
+ enqueue(new URL(p, origin).toString(), "seed_path");
3589
+ } catch {
3590
+ }
3591
+ }
3592
+ let captured = 0;
3593
+ let ordinal = 0;
3594
+ await onProgress?.(10, "Discovering public and auth flows");
3595
+ while (queue.length > 0 && captured < AUTH_DISCOVERY_MAX_SCREENS) {
3596
+ const current = queue.shift();
3597
+ try {
3598
+ const response = await page.goto(current.url, { waitUntil: "domcontentloaded", timeout: 15e3 }).catch(() => null);
3599
+ if (response && response.status && response.status() >= 400) continue;
3600
+ await page.waitForLoadState("networkidle", { timeout: 6e3 }).catch(() => {
3601
+ });
3602
+ await waitForStableDOM(page);
3603
+ await acceptCookieConsent(page, { waitMs: 400 }).catch(() => {
3604
+ });
3605
+ const finalUrl = page.url?.() ?? current.url;
3606
+ const captureKey = normalizeAuthUrl(finalUrl);
3607
+ if (!capturedKeys.has(captureKey)) {
3608
+ const result = await captureAuthDiscoveryScreen(page, opts, current.url, current.source, ordinal++);
3609
+ if (result.captured) {
3610
+ captured++;
3611
+ capturedKeys.add(captureKey);
3612
+ discoveredUrls.add(result.finalUrl);
3613
+ }
3614
+ }
3615
+ const candidates = await discoverAuthCandidates(page, origin);
3616
+ for (const link of candidates.links) enqueue(link.href, "page_link");
3617
+ const baseUrlForClicks = finalUrl;
3618
+ const clickLimit = Math.min(candidates.clicks.length, AUTH_DISCOVERY_MAX_CLICKS_PER_PAGE);
3619
+ for (let i = 0; i < clickLimit && captured < AUTH_DISCOVERY_MAX_SCREENS; i++) {
3620
+ await page.goto(baseUrlForClicks, { waitUntil: "domcontentloaded", timeout: 12e3 }).catch(() => {
3621
+ });
3622
+ await page.waitForLoadState("networkidle", { timeout: 4e3 }).catch(() => {
3623
+ });
3624
+ await waitForStableDOM(page);
3625
+ const freshCandidates = await discoverAuthCandidates(page, origin);
3626
+ const click = freshCandidates.clicks[i];
3627
+ if (!click) continue;
3628
+ const beforeUrl = page.url?.() ?? baseUrlForClicks;
3629
+ await page.locator(click.selector).first().click({ timeout: 4e3 }).catch(() => {
3630
+ });
3631
+ await page.waitForLoadState("networkidle", { timeout: 5e3 }).catch(() => {
3632
+ });
3633
+ await waitForStableDOM(page);
3634
+ const afterUrl = page.url?.() ?? beforeUrl;
3635
+ const sameUrlState = normalizeAuthUrl(beforeUrl) === normalizeAuthUrl(afterUrl);
3636
+ const stateKey = sameUrlState ? `${normalizeAuthUrl(afterUrl)}::${compactSlug(click.label)}` : normalizeAuthUrl(afterUrl);
3637
+ if (capturedKeys.has(stateKey)) continue;
3638
+ const result = await captureAuthDiscoveryScreen(page, opts, beforeUrl, "auth_click", ordinal++, {
3639
+ label: click.label,
3640
+ flowType: click.flowType,
3641
+ sameUrlState
3642
+ });
3643
+ if (result.captured) {
3644
+ captured++;
3645
+ capturedKeys.add(stateKey);
3646
+ discoveredUrls.add(result.finalUrl);
3647
+ const nested = await discoverAuthCandidates(page, origin);
3648
+ for (const link of nested.links) enqueue(link.href, "page_link");
3649
+ }
3650
+ }
3651
+ } catch (err) {
3652
+ logger.warn({ url: current.url, err: String(err?.message ?? err) }, "[crawler] auth-discovery: URL failed \u2014 skipping");
3653
+ }
3654
+ }
3655
+ await page.goto(opts.appUrl, { waitUntil: "domcontentloaded", timeout: 15e3 }).catch(() => {
3656
+ });
3657
+ await waitForStableDOM(page).catch(() => {
3658
+ });
3659
+ await AppBrain.saveCrawlEvidence(opts.tenantId, opts.projectId, {
3660
+ jobId: opts.jobId,
3661
+ type: "AUTH_DISCOVERY_SUMMARY",
3662
+ payload: { captured, discoveredUrls: Array.from(discoveredUrls), maxScreens: AUTH_DISCOVERY_MAX_SCREENS }
3663
+ }).catch(() => {
3664
+ });
3665
+ logger.info({ captured, discovered: discoveredUrls.size }, "[crawler] auth-discovery: phase complete");
3666
+ return { captured, discoveredUrls: Array.from(discoveredUrls) };
3667
+ }
3668
+ export {
3669
+ crawlProject,
3670
+ discoverLinksViaBrowser
3671
+ };
3672
+ //# sourceMappingURL=crawler.js.map