@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,75 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { toCypressSnippet, toSeleniumPythonDict, toWebdriverIOSnippet, exportSessionAs, type StorageState } from './session-export.js';
4
+
5
+ const sampleState: StorageState = {
6
+ cookies: [
7
+ { name: 'session_id', value: 'abc123', domain: '.example.com', path: '/', secure: true, httpOnly: true, sameSite: 'Lax', expires: 1893456000 },
8
+ ],
9
+ origins: [
10
+ { origin: 'https://example.com', localStorage: [{ name: 'authToken', value: 'xyz789' }] },
11
+ ],
12
+ };
13
+
14
+ test('toCypressSnippet: emits cy.setCookie for each cookie and cy.window().localStorage for each origin', () => {
15
+ const out = toCypressSnippet(sampleState);
16
+ assert.match(out, /cy\.setCookie\("session_id", "abc123"/);
17
+ assert.match(out, /domain: "\.example\.com"/);
18
+ assert.match(out, /secure: true/);
19
+ assert.match(out, /httpOnly: true/);
20
+ assert.match(out, /cy\.visit\("https:\/\/example\.com"\)/);
21
+ assert.match(out, /localStorage\.setItem\("authToken", "xyz789"\)/);
22
+ assert.match(out, /Cypress\.Commands\.add\('restoreZetaSession'/);
23
+ });
24
+
25
+ test('toSeleniumPythonDict: navigates to each cookie domain before add_cookie, and to each origin for localStorage', () => {
26
+ const out = toSeleniumPythonDict(sampleState);
27
+ assert.match(out, /driver\.get\("https:\/\/example\.com"\)/);
28
+ assert.match(out, /driver\.add_cookie\(/);
29
+ assert.match(out, /"name": "session_id"/);
30
+ assert.match(out, /"value": "abc123"/);
31
+ assert.match(out, /driver\.execute_script\(/);
32
+ });
33
+
34
+ test('toSeleniumPythonDict: emits Python True/False, not JSON true/false, for boolean fields', () => {
35
+ const out = toSeleniumPythonDict(sampleState);
36
+ // sampleState's cookie has secure: true — must render as Python's True.
37
+ assert.match(out, /"secure": True/);
38
+ assert.doesNotMatch(out, /:\s*true\b/);
39
+ assert.doesNotMatch(out, /:\s*false\b/);
40
+ });
41
+
42
+ test('toWebdriverIOSnippet: browser.setCookies() array shape close to Playwright cookies, plus localStorage via execute()', () => {
43
+ const out = toWebdriverIOSnippet(sampleState);
44
+ assert.match(out, /browser\.setCookies\(/);
45
+ assert.match(out, /"name": "session_id"/);
46
+ assert.match(out, /"domain": "\.example\.com"/);
47
+ assert.match(out, /browser\.url\("https:\/\/example\.com"\)/);
48
+ assert.match(out, /localStorage\.setItem\("authToken", "xyz789"\)/);
49
+ assert.match(out, /module\.exports = \{ restoreZetaSession \}/);
50
+ });
51
+
52
+ test('exportSessionAs: returns correct filename/mimeType per format', () => {
53
+ const cy = exportSessionAs('cypress', sampleState);
54
+ assert.equal(cy.filename, 'zeta-session.cy-commands.js');
55
+ assert.equal(cy.mimeType, 'application/javascript');
56
+
57
+ const py = exportSessionAs('selenium-python', sampleState);
58
+ assert.equal(py.filename, 'zeta_session.py');
59
+ assert.equal(py.mimeType, 'text/x-python');
60
+
61
+ const wdio = exportSessionAs('webdriverio', sampleState);
62
+ assert.equal(wdio.filename, 'zeta-session.wdio.js');
63
+ assert.equal(wdio.mimeType, 'application/javascript');
64
+ });
65
+
66
+ test('exportSessionAs: throws on an unknown format', () => {
67
+ assert.throws(() => exportSessionAs('unknown' as any, sampleState), /Unknown session export format/);
68
+ });
69
+
70
+ test('handles empty cookies/origins gracefully (no crash, minimal output)', () => {
71
+ const empty: StorageState = { cookies: [], origins: [] };
72
+ assert.doesNotThrow(() => toCypressSnippet(empty));
73
+ assert.doesNotThrow(() => toSeleniumPythonDict(empty));
74
+ assert.doesNotThrow(() => toWebdriverIOSnippet(empty));
75
+ });
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Converts a Playwright storageState (the format ZeTa already captures via
3
+ * session-capture.ts/live-view.ts and stores per-project) into snippets for
4
+ * other automation frameworks. Playwright's storageState is the de-facto
5
+ * standard for browser session export — see docs/UNIVERSAL-SESSION-CAPTURE-
6
+ * STRATEGY.md — so every other framework's import mechanism is a small,
7
+ * mechanical conversion away, not a new capture flow.
8
+ *
9
+ * Pure, dependency-free functions — no I/O, no network, fully unit-testable.
10
+ */
11
+
12
+ export interface StorageStateCookie {
13
+ name: string;
14
+ value: string;
15
+ domain: string;
16
+ path: string;
17
+ expires?: number;
18
+ httpOnly?: boolean;
19
+ secure?: boolean;
20
+ sameSite?: 'Strict' | 'Lax' | 'None';
21
+ }
22
+
23
+ export interface StorageStateOrigin {
24
+ origin: string;
25
+ localStorage: Array<{ name: string; value: string }>;
26
+ }
27
+
28
+ export interface StorageState {
29
+ cookies: StorageStateCookie[];
30
+ origins: StorageStateOrigin[];
31
+ }
32
+
33
+ export type SessionExportFormat = 'cypress' | 'selenium-python' | 'webdriverio';
34
+
35
+ function jsStringLiteral(s: string): string {
36
+ return JSON.stringify(s);
37
+ }
38
+
39
+ /** Python dict-literal repr — JSON.stringify's `true`/`false`/`null` aren't
40
+ * valid Python (Python needs `True`/`False`/`None`), so a plain
41
+ * JSON.stringify would emit syntactically invalid Python for any cookie
42
+ * with a boolean field. */
43
+ function pythonDictLiteral(obj: Record<string, unknown>): string {
44
+ const parts = Object.entries(obj).map(([k, v]) => {
45
+ let repr: string;
46
+ if (typeof v === 'boolean') repr = v ? 'True' : 'False';
47
+ else if (v === null || v === undefined) repr = 'None';
48
+ else if (typeof v === 'number') repr = String(v);
49
+ else repr = JSON.stringify(v);
50
+ return `${JSON.stringify(k)}: ${repr}`;
51
+ });
52
+ return `{${parts.join(', ')}}`;
53
+ }
54
+
55
+ /** cy.setCookie() + localStorage.setItem() inside a reusable custom command,
56
+ * following Cypress's documented cy.session() pattern (see references in
57
+ * docs/UNIVERSAL-SESSION-CAPTURE-STRATEGY.md). */
58
+ export function toCypressSnippet(state: StorageState): string {
59
+ const cookieLines = state.cookies.map((c) => {
60
+ const opts: string[] = [`domain: ${jsStringLiteral(c.domain)}`, `path: ${jsStringLiteral(c.path)}`];
61
+ if (c.secure) opts.push('secure: true');
62
+ if (c.httpOnly) opts.push('httpOnly: true');
63
+ if (c.sameSite) opts.push(`sameSite: ${jsStringLiteral(c.sameSite.toLowerCase())}`);
64
+ return ` cy.setCookie(${jsStringLiteral(c.name)}, ${jsStringLiteral(c.value)}, { ${opts.join(', ')} });`;
65
+ });
66
+
67
+ const localStorageBlocks = state.origins
68
+ .filter((o) => o.localStorage.length > 0)
69
+ .map((o) => {
70
+ const sets = o.localStorage
71
+ .map((kv) => ` window.localStorage.setItem(${jsStringLiteral(kv.name)}, ${jsStringLiteral(kv.value)});`)
72
+ .join('\n');
73
+ return ` cy.visit(${jsStringLiteral(o.origin)});\n cy.window().then((window) => {\n${sets}\n });`;
74
+ });
75
+
76
+ return [
77
+ '// Generated by ZeTa-AI — restores a captured browser session in Cypress.',
78
+ '// Usage: import this file and call restoreZetaSession() inside cy.session(), e.g.:',
79
+ "// cy.session('zeta-session', restoreZetaSession)",
80
+ "Cypress.Commands.add('restoreZetaSession', () => {",
81
+ ...cookieLines,
82
+ ...localStorageBlocks,
83
+ '});',
84
+ ].join('\n');
85
+ }
86
+
87
+ /** driver.add_cookie() (Selenium requires the browser already be on the
88
+ * cookie's domain before adding it) + execute_script() for localStorage,
89
+ * per Selenium's documented Cookie API. */
90
+ export function toSeleniumPythonDict(state: StorageState): string {
91
+ const byDomain = new Map<string, StorageStateCookie[]>();
92
+ for (const c of state.cookies) {
93
+ const list = byDomain.get(c.domain) ?? [];
94
+ list.push(c);
95
+ byDomain.set(c.domain, list);
96
+ }
97
+
98
+ const lines: string[] = [
99
+ '# Generated by ZeTa-AI — restores a captured browser session in Selenium.',
100
+ '# Selenium requires the driver to already be on a matching domain before',
101
+ '# add_cookie() will accept a cookie for it — navigate first, then restore.',
102
+ 'def restore_zeta_session(driver):',
103
+ ];
104
+
105
+ for (const [domain, cookies] of byDomain) {
106
+ lines.push(` driver.get(${JSON.stringify(`https://${domain.replace(/^\./, '')}`)})`);
107
+ for (const c of cookies) {
108
+ const cookieDict: Record<string, unknown> = { name: c.name, value: c.value, path: c.path };
109
+ if (c.expires && c.expires > 0) cookieDict.expiry = Math.floor(c.expires);
110
+ if (c.secure) cookieDict.secure = true;
111
+ lines.push(` driver.add_cookie(${pythonDictLiteral(cookieDict)})`);
112
+ }
113
+ }
114
+
115
+ for (const o of state.origins) {
116
+ if (o.localStorage.length === 0) continue;
117
+ lines.push(` driver.get(${JSON.stringify(o.origin)})`);
118
+ for (const kv of o.localStorage) {
119
+ lines.push(
120
+ ` driver.execute_script(${JSON.stringify(`window.localStorage.setItem(arguments[0], arguments[1]);`)}, ${JSON.stringify(kv.name)}, ${JSON.stringify(kv.value)})`,
121
+ );
122
+ }
123
+ }
124
+
125
+ return lines.join('\n');
126
+ }
127
+
128
+ /** browser.setCookies() accepts an array shape very close to Playwright's own
129
+ * cookie objects (per WebdriverIO's documented API) — closest 1:1 mapping
130
+ * of the three target formats. */
131
+ export function toWebdriverIOSnippet(state: StorageState): string {
132
+ const cookies = state.cookies.map((c) => ({
133
+ name: c.name,
134
+ value: c.value,
135
+ domain: c.domain,
136
+ path: c.path,
137
+ secure: !!c.secure,
138
+ httpOnly: !!c.httpOnly,
139
+ ...(c.expires && c.expires > 0 ? { expiry: Math.floor(c.expires) } : {}),
140
+ }));
141
+
142
+ const localStorageSets = state.origins
143
+ .filter((o) => o.localStorage.length > 0)
144
+ .map((o) => {
145
+ const sets = o.localStorage
146
+ .map((kv) => ` localStorage.setItem(${jsStringLiteral(kv.name)}, ${jsStringLiteral(kv.value)});`)
147
+ .join('\n');
148
+ return ` await browser.url(${jsStringLiteral(o.origin)});\n await browser.execute(() => {\n${sets}\n });`;
149
+ });
150
+
151
+ return [
152
+ '// Generated by ZeTa-AI — restores a captured browser session in WebdriverIO.',
153
+ 'async function restoreZetaSession(browser) {',
154
+ ` await browser.setCookies(${JSON.stringify(cookies, null, 2).split('\n').join('\n ')});`,
155
+ ...localStorageSets,
156
+ '}',
157
+ 'module.exports = { restoreZetaSession };',
158
+ ].join('\n');
159
+ }
160
+
161
+ export function exportSessionAs(format: SessionExportFormat, state: StorageState): { content: string; filename: string; mimeType: string } {
162
+ switch (format) {
163
+ case 'cypress':
164
+ return { content: toCypressSnippet(state), filename: 'zeta-session.cy-commands.js', mimeType: 'application/javascript' };
165
+ case 'selenium-python':
166
+ return { content: toSeleniumPythonDict(state), filename: 'zeta_session.py', mimeType: 'text/x-python' };
167
+ case 'webdriverio':
168
+ return { content: toWebdriverIOSnippet(state), filename: 'zeta-session.wdio.js', mimeType: 'application/javascript' };
169
+ default:
170
+ throw new Error(`Unknown session export format: ${format}`);
171
+ }
172
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Sitemap writer — generate XML sitemap from crawled screens.
3
+ *
4
+ * Compliant with sitemaps.org protocol (sitemap index + individual sitemap files).
5
+ * Splits into 50k-URL files per the spec when needed.
6
+ * Used for: SEO audit export, future crawl seed, and crawler/robots.txt comparison.
7
+ */
8
+
9
+ import * as fs from 'fs/promises';
10
+ import * as path from 'path';
11
+
12
+ export interface SitemapUrl {
13
+ loc: string;
14
+ lastmod?: string;
15
+ changefreq?: 'always' | 'hourly' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'never';
16
+ priority?: number; // 0.0–1.0
17
+ }
18
+
19
+ const XML_HEADER = '<?xml version="1.0" encoding="UTF-8"?>\n';
20
+ const SITEMAP_NS = 'xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"';
21
+
22
+ function escape(s: string): string {
23
+ return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&apos;');
24
+ }
25
+
26
+ function buildSitemapXml(urls: SitemapUrl[]): string {
27
+ const entries = urls.map(u => [
28
+ ' <url>',
29
+ ` <loc>${escape(u.loc)}</loc>`,
30
+ u.lastmod ? ` <lastmod>${u.lastmod}</lastmod>` : '',
31
+ u.changefreq ? ` <changefreq>${u.changefreq}</changefreq>` : '',
32
+ u.priority !== undefined ? ` <priority>${u.priority.toFixed(1)}</priority>` : '',
33
+ ' </url>',
34
+ ].filter(Boolean).join('\n')).join('\n');
35
+
36
+ return `${XML_HEADER}<urlset ${SITEMAP_NS}>\n${entries}\n</urlset>`;
37
+ }
38
+
39
+ export async function writeSitemap(
40
+ urls: SitemapUrl[],
41
+ outputPath: string,
42
+ ): Promise<void> {
43
+ await fs.mkdir(path.dirname(outputPath), { recursive: true });
44
+ await fs.writeFile(outputPath, buildSitemapXml(urls), 'utf8');
45
+ }
46
+
47
+ /** Convert crawled screen records to SitemapUrl objects */
48
+ export function screensToSitemapUrls(
49
+ screens: Array<{ url: string; updatedAt?: Date; depth?: number }>,
50
+ ): SitemapUrl[] {
51
+ return screens
52
+ .filter(s => {
53
+ try { return new URL(s.url).protocol.startsWith('http'); } catch { return false; }
54
+ })
55
+ .map(s => ({
56
+ loc: s.url,
57
+ lastmod: s.updatedAt ? s.updatedAt.toISOString().split('T')[0] : undefined,
58
+ changefreq: 'weekly' as const,
59
+ priority: s.depth === 0 ? 1.0 : s.depth === 1 ? 0.8 : 0.6,
60
+ }));
61
+ }
62
+
63
+ export function buildSitemapString(urls: SitemapUrl[]): string {
64
+ return buildSitemapXml(urls);
65
+ }
@@ -0,0 +1,131 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import http from 'node:http';
4
+ import os from 'node:os';
5
+ import { XMLParser } from 'fast-xml-parser';
6
+ import { extractUrlsFromParsedSitemap, fetchSitemapUrls, discoverSitemapUrls } from './sitemap.js';
7
+
8
+ const parser = new XMLParser();
9
+
10
+ test('extractUrlsFromParsedSitemap: parses a plain <urlset> sitemap', () => {
11
+ const xml = `<?xml version="1.0" encoding="UTF-8"?>
12
+ <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
13
+ <url><loc>https://example.com/</loc></url>
14
+ <url><loc>https://example.com/pricing</loc></url>
15
+ <url><loc>https://example.com/about</loc></url>
16
+ </urlset>`;
17
+ const { pageUrls, subSitemapUrls } = extractUrlsFromParsedSitemap(parser.parse(xml), 'https://example.com');
18
+ assert.deepEqual(pageUrls.sort(), ['https://example.com/', 'https://example.com/about', 'https://example.com/pricing'].sort());
19
+ assert.deepEqual(subSitemapUrls, []);
20
+ });
21
+
22
+ test('extractUrlsFromParsedSitemap: parses a <sitemapindex> into sub-sitemap URLs', () => {
23
+ const xml = `<?xml version="1.0" encoding="UTF-8"?>
24
+ <sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
25
+ <sitemap><loc>https://example.com/sitemap-pages.xml</loc></sitemap>
26
+ <sitemap><loc>https://example.com/sitemap-blog.xml</loc></sitemap>
27
+ </sitemapindex>`;
28
+ const { pageUrls, subSitemapUrls } = extractUrlsFromParsedSitemap(parser.parse(xml), 'https://example.com');
29
+ assert.deepEqual(pageUrls, []);
30
+ assert.deepEqual(subSitemapUrls, ['https://example.com/sitemap-pages.xml', 'https://example.com/sitemap-blog.xml']);
31
+ });
32
+
33
+ test('extractUrlsFromParsedSitemap: filters out cross-origin URLs', () => {
34
+ const xml = `<urlset>
35
+ <url><loc>https://example.com/page</loc></url>
36
+ <url><loc>https://attacker.example/evil</loc></url>
37
+ </urlset>`;
38
+ const { pageUrls } = extractUrlsFromParsedSitemap(parser.parse(xml), 'https://example.com');
39
+ assert.deepEqual(pageUrls, ['https://example.com/page']);
40
+ });
41
+
42
+ test('extractUrlsFromParsedSitemap: dedupes repeated URLs', () => {
43
+ const xml = `<urlset>
44
+ <url><loc>https://example.com/a</loc></url>
45
+ <url><loc>https://example.com/a</loc></url>
46
+ </urlset>`;
47
+ const { pageUrls } = extractUrlsFromParsedSitemap(parser.parse(xml), 'https://example.com');
48
+ assert.deepEqual(pageUrls, ['https://example.com/a']);
49
+ });
50
+
51
+ test('extractUrlsFromParsedSitemap: a single <url> entry is not an array (fast-xml-parser quirk) — still handled', () => {
52
+ const xml = `<urlset><url><loc>https://example.com/only</loc></url></urlset>`;
53
+ const { pageUrls } = extractUrlsFromParsedSitemap(parser.parse(xml), 'https://example.com');
54
+ assert.deepEqual(pageUrls, ['https://example.com/only']);
55
+ });
56
+
57
+ test('extractUrlsFromParsedSitemap: handles empty/malformed input without throwing', () => {
58
+ assert.deepEqual(extractUrlsFromParsedSitemap({}, 'https://example.com'), { pageUrls: [], subSitemapUrls: [] });
59
+ assert.deepEqual(extractUrlsFromParsedSitemap(null, 'https://example.com'), { pageUrls: [], subSitemapUrls: [] });
60
+ assert.deepEqual(extractUrlsFromParsedSitemap({ urlset: { url: [{ notLoc: 'x' }] } }, 'https://example.com'), { pageUrls: [], subSitemapUrls: [] });
61
+ });
62
+
63
+ // --- Live end-to-end: a real local HTTP server, real fetch, real XML parse ---
64
+ // Bound to the sandbox's actual (non-loopback) address — assertCrawlUrlSafe
65
+ // deliberately blocks literal localhost/127.0.0.1 (meaningless as a crawl
66
+ // target, would just hit the crawler container itself) but allows other
67
+ // addresses, matching how a real deployment reaches a tenant's internal app.
68
+ function getNonLoopbackAddress(): string {
69
+ for (const ifaces of Object.values(os.networkInterfaces())) {
70
+ for (const iface of ifaces as any[]) {
71
+ if (iface.family === 'IPv4' && !iface.internal) return iface.address;
72
+ }
73
+ }
74
+ throw new Error('No non-loopback IPv4 address found on this host — cannot run the live sitemap test');
75
+ }
76
+
77
+ test('fetchSitemapUrls: live end-to-end against a real HTTP server serving a sitemap index', async () => {
78
+ const host = getNonLoopbackAddress();
79
+ const indexXml = `<?xml version="1.0"?>
80
+ <sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
81
+ <sitemap><loc>http://${host}:PORT/sub1.xml</loc></sitemap>
82
+ <sitemap><loc>http://${host}:PORT/sub2.xml</loc></sitemap>
83
+ </sitemapindex>`;
84
+ const sub1Xml = `<urlset><url><loc>http://${host}:PORT/page1</loc></url><url><loc>http://${host}:PORT/page2</loc></url></urlset>`;
85
+ const sub2Xml = `<urlset><url><loc>http://${host}:PORT/page3</loc></url></urlset>`;
86
+
87
+ const server = http.createServer((req, res) => {
88
+ res.writeHead(200, { 'Content-Type': 'application/xml' });
89
+ if (req.url === '/sitemap.xml') res.end(indexXml.replaceAll('PORT', String(port)));
90
+ else if (req.url === '/sub1.xml') res.end(sub1Xml.replaceAll('PORT', String(port)));
91
+ else if (req.url === '/sub2.xml') res.end(sub2Xml.replaceAll('PORT', String(port)));
92
+ else { res.writeHead(404); res.end(); }
93
+ });
94
+ await new Promise<void>((resolve) => server.listen(0, host, resolve));
95
+ const port = (server.address() as any).port;
96
+
97
+ try {
98
+ const origin = `http://${host}:${port}`;
99
+ const urls = await fetchSitemapUrls(`${origin}/sitemap.xml`, origin);
100
+ assert.deepEqual(
101
+ urls.sort(),
102
+ [`${origin}/page1`, `${origin}/page2`, `${origin}/page3`].sort(),
103
+ );
104
+ } finally {
105
+ server.close();
106
+ }
107
+ });
108
+
109
+ test('discoverSitemapUrls: defaults to /sitemap.xml at the app origin and returns [] gracefully on 404', async () => {
110
+ const host = getNonLoopbackAddress();
111
+ const server = http.createServer((_req, res) => { res.writeHead(404); res.end(); });
112
+ await new Promise<void>((resolve) => server.listen(0, host, resolve));
113
+ const port = (server.address() as any).port;
114
+
115
+ try {
116
+ const urls = await discoverSitemapUrls(`http://${host}:${port}/app`);
117
+ assert.deepEqual(urls, []);
118
+ } finally {
119
+ server.close();
120
+ }
121
+ });
122
+
123
+ test('discoverSitemapUrls: never throws for an invalid appUrl', async () => {
124
+ const urls = await discoverSitemapUrls('not-a-valid-url');
125
+ assert.deepEqual(urls, []);
126
+ });
127
+
128
+ test('discoverSitemapUrls: is blocked (returns []) for a disallowed target like localhost — assertCrawlUrlSafe still applies', async () => {
129
+ const urls = await discoverSitemapUrls('http://localhost:1/app');
130
+ assert.deepEqual(urls, []);
131
+ });
package/src/sitemap.ts ADDED
@@ -0,0 +1,224 @@
1
+ /**
2
+ * Sitemap.xml discovery — seeds the crawl queue from a project's sitemap
3
+ * instead of (or alongside) plain link-following, matching crawl4AI's
4
+ * sitemap-seeding capability. Best-effort throughout: most sites don't have
5
+ * a sitemap, or it 404s, or it's malformed — none of that should ever fail
6
+ * a crawl, it just means sitemap seeding contributes nothing this run.
7
+ */
8
+ import { XMLParser } from 'fast-xml-parser';
9
+ import { ProxyAgent } from 'undici';
10
+ import { assertCrawlUrlSafe } from '@detiq/core';
11
+ import { logger } from './logger.js';
12
+
13
+ const FETCH_TIMEOUT_MS = 10_000;
14
+ const MAX_SITEMAP_INDEX_DEPTH = 3; // sitemap index -> sub-sitemaps -> (no deeper nesting expected)
15
+ const MAX_SUB_SITEMAPS = 50; // bound fan-out from a single sitemap index
16
+ const MAX_URLS = 5000; // sane upper bound so a huge/malicious sitemap can't blow up memory
17
+
18
+ function toArray<T>(x: T | T[] | undefined | null): T[] {
19
+ if (x == null) return [];
20
+ return Array.isArray(x) ? x : [x];
21
+ }
22
+
23
+ /** Registrable-host key: lowercased hostname with a leading `www.` stripped. */
24
+ function hostKey(hostname: string): string {
25
+ return hostname.replace(/^www\./i, '').toLowerCase();
26
+ }
27
+
28
+ /**
29
+ * Same-site check that tolerates www/non-www and http/https differences — a sitemap on
30
+ * `vercel.com` is valid for a project pointed at `www.vercel.com` (and vice versa).
31
+ */
32
+ export function sameSite(candidateUrl: string, siteOrigin: string): boolean {
33
+ try {
34
+ const originHost = /^https?:\/\//i.test(siteOrigin) ? new URL(siteOrigin).hostname : siteOrigin;
35
+ return hostKey(new URL(candidateUrl).hostname) === hostKey(originHost);
36
+ } catch {
37
+ return false;
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Pulls page URLs and sub-sitemap URLs out of an already-XML-parsed sitemap
43
+ * document (either a <urlset> or a <sitemapindex>), filtered to same-origin
44
+ * and deduped. Pure function — no I/O — for direct unit testing without a
45
+ * real HTTP fetch.
46
+ */
47
+ export function extractUrlsFromParsedSitemap(parsed: any, origin: string): { pageUrls: string[]; subSitemapUrls: string[] } {
48
+ const rawPageUrls: string[] = [];
49
+ let subSitemapUrls: string[] = [];
50
+
51
+ if (parsed?.sitemapindex) {
52
+ subSitemapUrls = toArray(parsed.sitemapindex.sitemap)
53
+ .map((s: any) => s?.loc)
54
+ .filter((loc: any): loc is string => typeof loc === 'string')
55
+ .slice(0, MAX_SUB_SITEMAPS);
56
+ }
57
+
58
+ if (parsed?.urlset) {
59
+ rawPageUrls.push(
60
+ ...toArray(parsed.urlset.url)
61
+ .map((u: any) => u?.loc)
62
+ .filter((loc: any): loc is string => typeof loc === 'string'),
63
+ );
64
+ }
65
+
66
+ // Same-site only (www/protocol-tolerant) — a sitemap listing external URLs shouldn't seed this project's queue.
67
+ const sameOrigin = rawPageUrls.filter((u) => sameSite(u, origin));
68
+
69
+ return { pageUrls: [...new Set(sameOrigin)].slice(0, MAX_URLS), subSitemapUrls };
70
+ }
71
+
72
+ async function fetchAndParseXml(url: string, dispatcher?: any): Promise<any | null> {
73
+ // Same SSRF posture as the crawl itself (assertCrawlUrlSafe, not the
74
+ // stricter webhook-oriented assertResolvesToPublicAddress) — a sitemap
75
+ // living on a tenant's internal/VPN-only staging server is a legitimate
76
+ // crawl target, not an attack; only loopback/cloud-metadata are blocked.
77
+ try {
78
+ assertCrawlUrlSafe(url);
79
+ } catch {
80
+ return null;
81
+ }
82
+ try {
83
+ // Redirects followed normally here (unlike the webhook-dispatch.ts
84
+ // 'manual' pattern) — an http→https or bare→www redirect on the
85
+ // sitemap is completely ordinary, and Playwright's actual page
86
+ // navigation later in the crawl already follows redirects to whatever
87
+ // this same site sends it to; a sitemap fetch isn't a more sensitive
88
+ // operation than that.
89
+ const res = await fetch(url, {
90
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
91
+ headers: { 'User-Agent': 'ZeTa-AI-Crawler/1.0 (+sitemap-discovery)' },
92
+ ...(dispatcher ? { dispatcher } : {}),
93
+ });
94
+ if (!res.ok) return null;
95
+ const text = await res.text();
96
+ return new XMLParser().parse(text);
97
+ } catch {
98
+ return null;
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Fetches and parses a sitemap (or sitemap index, recursed up to a small
104
+ * depth) into a flat, deduped list of same-origin URLs.
105
+ */
106
+ export async function fetchSitemapUrls(sitemapUrl: string, origin: string, depth = 0, dispatcher?: any): Promise<string[]> {
107
+ if (depth > MAX_SITEMAP_INDEX_DEPTH) return [];
108
+ const parsed = await fetchAndParseXml(sitemapUrl, dispatcher);
109
+ if (!parsed) return [];
110
+
111
+ const { pageUrls, subSitemapUrls } = extractUrlsFromParsedSitemap(parsed, origin);
112
+ const collected = [...pageUrls];
113
+
114
+ for (const subUrl of subSitemapUrls) {
115
+ if (collected.length >= MAX_URLS) break;
116
+ collected.push(...(await fetchSitemapUrls(subUrl, origin, depth + 1, dispatcher)));
117
+ }
118
+
119
+ return [...new Set(collected)].slice(0, MAX_URLS);
120
+ }
121
+
122
+ /**
123
+ * Discovers a project's sitemap (default `${origin}/sitemap.xml`, or an
124
+ * explicit override) and returns the URLs it lists. Never throws.
125
+ */
126
+ export async function discoverSitemapUrls(appUrl: string, sitemapUrl?: string): Promise<string[]> {
127
+ let origin: string;
128
+ try {
129
+ origin = new URL(appUrl).origin;
130
+ } catch {
131
+ return [];
132
+ }
133
+ const target = sitemapUrl || `${origin}/sitemap.xml`;
134
+ try {
135
+ return await fetchSitemapUrls(target, origin);
136
+ } catch (err) {
137
+ logger.warn({ err: String(err), target }, '[sitemap] discovery failed, continuing without it');
138
+ return [];
139
+ }
140
+ }
141
+
142
+ /**
143
+ * Build an undici ProxyAgent from a proxy URL. Parses credentials manually because
144
+ * DataImpulse-style usernames contain commas (country codes) that break new URL().
145
+ * Returns undefined for no/auto proxy — the caller then fetches directly.
146
+ */
147
+ export function buildProxyDispatcher(proxyUrl?: string): ProxyAgent | undefined {
148
+ if (!proxyUrl || proxyUrl === 'auto') return undefined;
149
+ try {
150
+ const m = proxyUrl.match(/^(https?):\/\/(?:([^:@]*)(?::([^@]*))?@)?(.+)$/);
151
+ if (!m) return new ProxyAgent(proxyUrl);
152
+ const [, scheme, user, pass, hostPort] = m;
153
+ const uri = `${scheme}://${hostPort}`;
154
+ if (user) {
155
+ const token = 'Basic ' + Buffer.from(`${decodeURIComponent(user)}:${decodeURIComponent(pass ?? '')}`).toString('base64');
156
+ return new ProxyAgent({ uri, token });
157
+ }
158
+ return new ProxyAgent({ uri });
159
+ } catch {
160
+ return undefined;
161
+ }
162
+ }
163
+
164
+ /** Extract same-origin `Sitemap:` directives from a robots.txt body. */
165
+ export function parseRobotsSitemaps(robotsTxt: string, origin: string): string[] {
166
+ const out: string[] = [];
167
+ for (const line of robotsTxt.split(/\r?\n/)) {
168
+ const m = line.match(/^\s*sitemap:\s*(\S+)/i);
169
+ if (m && sameSite(m[1], origin)) out.push(m[1]);
170
+ }
171
+ return [...new Set(out)];
172
+ }
173
+
174
+ export interface UrlCandidate {
175
+ url: string;
176
+ source: 'sitemap' | 'robots' | 'link';
177
+ }
178
+
179
+ /**
180
+ * Pre-crawl URL discovery for the review-before-crawl flow: reads robots.txt Sitemap
181
+ * directives and sitemap.xml (recursed, same-origin), proxy-aware. Returns a deduped
182
+ * candidate list the user can review and seed before launching the Playwright crawl.
183
+ * Never throws — bot-protected sites (Akamai/Cloudflare) simply yield an empty list.
184
+ */
185
+ export async function discoverProjectUrls(
186
+ appUrl: string,
187
+ opts?: { sitemapUrl?: string; proxyUrl?: string },
188
+ ): Promise<{ candidates: UrlCandidate[]; sitemapFound: boolean }> {
189
+ let origin: string;
190
+ try { origin = new URL(appUrl).origin; } catch { return { candidates: [], sitemapFound: false }; }
191
+
192
+ const dispatcher = buildProxyDispatcher(opts?.proxyUrl);
193
+
194
+ // 1. robots.txt → declared sitemaps
195
+ let robotsSitemaps: string[] = [];
196
+ try {
197
+ assertCrawlUrlSafe(`${origin}/robots.txt`);
198
+ const res = await fetch(`${origin}/robots.txt`, {
199
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
200
+ headers: { 'User-Agent': 'ZeTa-AI-Crawler/1.0 (+url-discovery)' },
201
+ ...(dispatcher ? { dispatcher } : {}),
202
+ });
203
+ if (res.ok) robotsSitemaps = parseRobotsSitemaps(await res.text(), origin);
204
+ } catch { /* best-effort */ }
205
+
206
+ // 2. sitemap targets: explicit override + robots-declared + conventional default
207
+ const robotsSet = new Set(robotsSitemaps);
208
+ const sitemapTargets = [...new Set([
209
+ ...(opts?.sitemapUrl ? [opts.sitemapUrl] : []),
210
+ ...robotsSitemaps,
211
+ `${origin}/sitemap.xml`,
212
+ ])];
213
+
214
+ const seen = new Map<string, UrlCandidate>();
215
+ let sitemapFound = false;
216
+ for (const sm of sitemapTargets) {
217
+ const urls = await fetchSitemapUrls(sm, origin, 0, dispatcher);
218
+ if (urls.length) sitemapFound = true;
219
+ const source: UrlCandidate['source'] = robotsSet.has(sm) ? 'robots' : 'sitemap';
220
+ for (const u of urls) if (!seen.has(u)) seen.set(u, { url: u, source });
221
+ }
222
+
223
+ return { candidates: [...seen.values()].slice(0, MAX_URLS), sitemapFound };
224
+ }