@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,97 @@
1
+ /**
2
+ * Feature flag + A/B test detector.
3
+ *
4
+ * Detects popular feature flag / experiment SDKs loaded on the page:
5
+ * LaunchDarkly, Optimizely, Split.io, Unleash, GrowthBook, Statsig, Flagsmith, Amplitude Experiment.
6
+ *
7
+ * Also detects common A/B test patterns: data-testid attributes, gtm.js experiment layers,
8
+ * Optimizely variation IDs, VWO, etc.
9
+ *
10
+ * Why: ZeTa needs to know when a page shows different content to different users
11
+ * so test generation can account for variants and not generate flaky tests.
12
+ */
13
+
14
+ import type { Page } from 'playwright';
15
+
16
+ export interface FeatureFlagInfo {
17
+ sdk: string;
18
+ confidence: 'high' | 'medium' | 'low';
19
+ details?: string;
20
+ }
21
+
22
+ export interface AbTestInfo {
23
+ tool: string;
24
+ experiments?: string[];
25
+ }
26
+
27
+ export interface FeatureFlagDetectionResult {
28
+ url: string;
29
+ flagSdks: FeatureFlagInfo[];
30
+ abTools: AbTestInfo[];
31
+ hasAnyFlags: boolean;
32
+ detectedAt: string;
33
+ }
34
+
35
+ // SDK detectors — checked against window object + script URLs
36
+ const FLAG_SDK_CHECKS: Array<{
37
+ sdk: string;
38
+ windowKeys?: string[];
39
+ scriptPattern?: RegExp;
40
+ }> = [
41
+ { sdk: 'LaunchDarkly', windowKeys: ['LDClient', 'LaunchDarkly', 'ldClient'], scriptPattern: /launchdarkly/i },
42
+ { sdk: 'Optimizely', windowKeys: ['optimizely', 'optimizelySdk'], scriptPattern: /optimizely/i },
43
+ { sdk: 'Split.io', windowKeys: ['splitio', 'SplitFactory'], scriptPattern: /split\.io/i },
44
+ { sdk: 'GrowthBook', windowKeys: ['growthbook', 'GrowthBook'], scriptPattern: /growthbook/i },
45
+ { sdk: 'Statsig', windowKeys: ['statsig', 'StatsigClient'], scriptPattern: /statsig/i },
46
+ { sdk: 'Flagsmith', windowKeys: ['flagsmith'], scriptPattern: /flagsmith/i },
47
+ { sdk: 'Unleash', windowKeys: ['unleash', 'UnleashClient'], scriptPattern: /unleash/i },
48
+ { sdk: 'VWO', windowKeys: ['vwo', '_vwo'], scriptPattern: /vwo\.com/i },
49
+ { sdk: 'Google Optimize', windowKeys: ['gaData', 'google_optimize'], scriptPattern: /googleoptimize/i },
50
+ { sdk: 'AB Tasty', windowKeys: ['ABTasty'], scriptPattern: /abtasty/i },
51
+ ];
52
+
53
+ export async function detectFeatureFlags(page: Page, url: string): Promise<FeatureFlagDetectionResult> {
54
+ const detectedAt = new Date().toISOString();
55
+
56
+ const result = await page.evaluate((checks) => {
57
+ const flagSdks: any[] = [];
58
+ const scripts = Array.from(document.querySelectorAll('script[src]')).map((s: any) => s.src ?? '');
59
+
60
+ for (const check of checks) {
61
+ let found = false;
62
+ let confidence = 'low';
63
+
64
+ // Check window keys
65
+ if (check.windowKeys?.some((k: string) => k in (window as any))) {
66
+ found = true;
67
+ confidence = 'high';
68
+ }
69
+ // Check script URLs
70
+ if (!found && check.scriptPattern) {
71
+ const re = new RegExp(check.scriptPattern.source, 'i');
72
+ const matchingSrc = scripts.find(s => re.test(s));
73
+ if (matchingSrc) { found = true; confidence = 'medium'; }
74
+ }
75
+ if (found) flagSdks.push({ sdk: check.sdk, confidence, details: undefined });
76
+ }
77
+
78
+ // A/B tools
79
+ const abTools: any[] = [];
80
+ if ('_vwo_code' in window || '_vis_opt_queue' in (window as any)) abTools.push({ tool: 'VWO', experiments: [] });
81
+ if ('dataLayer' in window) {
82
+ const dl = (window as any).dataLayer ?? [];
83
+ const expEvents = dl.filter((e: any) => e?.event?.includes('experiment') || e?.experimentId);
84
+ if (expEvents.length > 0) abTools.push({ tool: 'Google Tag Manager Experiments', experiments: expEvents.slice(0, 5).map((e: any) => e.experimentId ?? '') });
85
+ }
86
+
87
+ return { flagSdks, abTools };
88
+ }, FLAG_SDK_CHECKS as any).catch(() => ({ flagSdks: [], abTools: [] }));
89
+
90
+ return {
91
+ url,
92
+ flagSdks: result.flagSdks,
93
+ abTools: result.abTools,
94
+ hasAnyFlags: result.flagSdks.length > 0 || result.abTools.length > 0,
95
+ detectedAt,
96
+ };
97
+ }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Form detector — extract form metadata from crawled pages.
3
+ *
4
+ * For each form found on a page, captures: action URL, method, all input fields
5
+ * with their types/names/required/validation constraints, and submit button text.
6
+ *
7
+ * This data feeds ZeTa's test generator to create form validation test cases
8
+ * automatically (happy path + boundary/error cases per field type).
9
+ */
10
+
11
+ import type { Page } from 'playwright';
12
+
13
+ export interface FormField {
14
+ name: string;
15
+ type: string; // text, email, password, number, select, textarea, checkbox, radio, file, date
16
+ label?: string;
17
+ required: boolean;
18
+ placeholder?: string;
19
+ minLength?: number;
20
+ maxLength?: number;
21
+ min?: string;
22
+ max?: string;
23
+ pattern?: string;
24
+ options?: string[]; // for select/radio
25
+ autocomplete?: string;
26
+ }
27
+
28
+ export interface DetectedForm {
29
+ index: number;
30
+ action?: string;
31
+ method: string;
32
+ fields: FormField[];
33
+ submitText?: string;
34
+ hasFileUpload: boolean;
35
+ purpose: 'login' | 'search' | 'registration' | 'contact' | 'checkout' | 'settings' | 'unknown';
36
+ }
37
+
38
+ function guessPurpose(fields: FormField[], action?: string): DetectedForm['purpose'] {
39
+ const names = fields.map(f => f.name?.toLowerCase() + (f.label ?? '').toLowerCase()).join(' ');
40
+ if (/password/.test(names) && /email|username|user/.test(names) && fields.length <= 4) return 'login';
41
+ if (/password/.test(names) && /confirm|repeat/.test(names)) return 'registration';
42
+ if (/search|query|q\b/.test(names) && fields.length <= 3) return 'search';
43
+ if (/card|payment|cvv|expir/.test(names)) return 'checkout';
44
+ if (/message|subject|contact/.test(names)) return 'contact';
45
+ if (/setting|preference|profile/.test(names)) return 'settings';
46
+ return 'unknown';
47
+ }
48
+
49
+ export async function detectForms(page: Page): Promise<DetectedForm[]> {
50
+ try {
51
+ return await page.$$eval('form', (forms) => forms.map((form: any, index: number) => {
52
+ const inputs = Array.from(form.querySelectorAll('input,select,textarea,button[type="submit"]'));
53
+ const fields: any[] = [];
54
+
55
+ for (const input of inputs as any[]) {
56
+ const tag = input.tagName.toLowerCase();
57
+ const type = tag === 'button' ? 'submit' : (input.type || tag);
58
+ if (['submit', 'reset', 'button', 'hidden'].includes(type)) continue;
59
+
60
+ // Find associated label
61
+ let label = '';
62
+ if (input.id) {
63
+ const lbl = form.querySelector(`label[for="${input.id}"]`);
64
+ if (lbl) label = (lbl.textContent ?? '').trim();
65
+ }
66
+ if (!label) {
67
+ const parent = input.closest('label');
68
+ if (parent) label = (parent.textContent ?? '').replace(input.value ?? '', '').trim().slice(0, 80);
69
+ }
70
+
71
+ const options = tag === 'select'
72
+ ? Array.from(input.options ?? []).map((o: any) => o.text?.trim()).filter(Boolean).slice(0, 20)
73
+ : tag === 'input' && type === 'radio'
74
+ ? [input.value]
75
+ : undefined;
76
+
77
+ fields.push({
78
+ name: input.name || input.id || '',
79
+ type,
80
+ label: label || undefined,
81
+ required: !!input.required,
82
+ placeholder: input.placeholder || undefined,
83
+ minLength: input.minLength > 0 ? input.minLength : undefined,
84
+ maxLength: input.maxLength > 0 && input.maxLength < 524288 ? input.maxLength : undefined,
85
+ min: input.min || undefined,
86
+ max: input.max || undefined,
87
+ pattern: input.pattern || undefined,
88
+ options,
89
+ autocomplete: input.autocomplete || undefined,
90
+ });
91
+ }
92
+
93
+ const submitBtn = form.querySelector('button[type="submit"],input[type="submit"]') as any;
94
+ const hasFileUpload = !!form.querySelector('input[type="file"]');
95
+ const action = form.action || undefined;
96
+ const method = (form.method || 'get').toUpperCase();
97
+
98
+ return { index, action, method, fields, submitText: submitBtn?.textContent?.trim() || submitBtn?.value || undefined, hasFileUpload };
99
+ })).then(forms => forms.map((f: any) => ({ ...f, purpose: guessPurpose(f.fields, f.action) })));
100
+ } catch {
101
+ return [];
102
+ }
103
+ }
@@ -0,0 +1,64 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { CrawlFrontier } from './frontier.js';
4
+
5
+ test('CrawlFrontier: BFS dequeues in FIFO order, identical to a plain array shift()', () => {
6
+ const f = new CrawlFrontier('BFS', ['https://a.com/1', 'https://a.com/2']);
7
+ f.push('https://a.com/3');
8
+ assert.equal(f.shift(), 'https://a.com/1');
9
+ assert.equal(f.shift(), 'https://a.com/2');
10
+ assert.equal(f.shift(), 'https://a.com/3');
11
+ assert.equal(f.shift(), undefined);
12
+ });
13
+
14
+ test('CrawlFrontier: DFS dequeues in LIFO order', () => {
15
+ const f = new CrawlFrontier('DFS', ['https://a.com/1', 'https://a.com/2']);
16
+ f.push('https://a.com/3');
17
+ assert.equal(f.shift(), 'https://a.com/3');
18
+ assert.equal(f.shift(), 'https://a.com/2');
19
+ assert.equal(f.shift(), 'https://a.com/1');
20
+ });
21
+
22
+ test('CrawlFrontier: BEST_FIRST prefers shallower paths over deeper ones', () => {
23
+ const f = new CrawlFrontier('BEST_FIRST', [
24
+ 'https://a.com/products/electronics/phones/model-x',
25
+ 'https://a.com/about',
26
+ ]);
27
+ assert.equal(f.shift(), 'https://a.com/about');
28
+ assert.equal(f.shift(), 'https://a.com/products/electronics/phones/model-x');
29
+ });
30
+
31
+ test('CrawlFrontier: BEST_FIRST boosts URLs matching keywords ahead of shallower non-matching ones', () => {
32
+ const f = new CrawlFrontier('BEST_FIRST', ['https://a.com/'], ['pricing']);
33
+ f.push('https://a.com/deep/nested/pricing/plans');
34
+ // "/" has depth 0 (score 0); the pricing URL has depth 4 (score -4) but +10 for the
35
+ // keyword match, netting +6 — the keyword-matching page should win despite depth.
36
+ assert.equal(f.shift(), 'https://a.com/deep/nested/pricing/plans');
37
+ assert.equal(f.shift(), 'https://a.com/');
38
+ });
39
+
40
+ test('CrawlFrontier: length reflects push/shift correctly across all strategies', () => {
41
+ for (const strategy of ['BFS', 'DFS', 'BEST_FIRST'] as const) {
42
+ const f = new CrawlFrontier(strategy, ['https://a.com/1']);
43
+ assert.equal(f.length, 1);
44
+ f.push('https://a.com/2');
45
+ assert.equal(f.length, 2);
46
+ f.shift();
47
+ assert.equal(f.length, 1);
48
+ }
49
+ });
50
+
51
+ test('CrawlFrontier: filter returns URLs matching the predicate without mutating the frontier', () => {
52
+ const f = new CrawlFrontier('BFS', ['https://a.com/1', 'https://a.com/2', 'https://a.com/3']);
53
+ const matched = f.filter((u) => u.endsWith('/2'));
54
+ assert.deepEqual(matched, ['https://a.com/2']);
55
+ assert.equal(f.length, 3); // unchanged
56
+ });
57
+
58
+ test('CrawlFrontier: malformed URLs never throw when scored under BEST_FIRST', () => {
59
+ assert.doesNotThrow(() => {
60
+ const f = new CrawlFrontier('BEST_FIRST', ['not-a-valid-url', 'https://a.com/ok']);
61
+ f.shift();
62
+ f.shift();
63
+ });
64
+ });
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Configurable crawl traversal strategy — matches crawl4AI's BFS/DFS/
3
+ * Best-First deep-crawl strategies (ZeTa previously had a single hardcoded
4
+ * FIFO queue, i.e. always BFS).
5
+ *
6
+ * Array-like on purpose (`length`, `shift()`, `push()`, `filter()`) so it
7
+ * drops into crawler.ts's existing queue call sites with a rename, not a
8
+ * rewrite — BFS's `shift()` behavior is byte-for-byte the same as the plain
9
+ * array it replaces, so BFS (the default) has zero behavior change.
10
+ */
11
+
12
+ export type TraversalStrategy = 'BFS' | 'DFS' | 'BEST_FIRST';
13
+
14
+ interface FrontierEntry {
15
+ url: string;
16
+ score: number;
17
+ }
18
+
19
+ /**
20
+ * Best-First scoring heuristic: shallower paths score higher (breadth-like
21
+ * bias toward top-level sections before deep sub-pages), boosted further by
22
+ * any user-supplied keyword appearing in the URL. Simple and transparent by
23
+ * design — crawl4AI's own default scorer is a similarly simple keyword-
24
+ * relevance heuristic, not a learned model.
25
+ */
26
+ function scoreUrl(url: string, keywords: string[]): number {
27
+ try {
28
+ const u = new URL(url);
29
+ const segments = u.pathname.split('/').filter(Boolean);
30
+ let score = -segments.length;
31
+ if (keywords.length > 0) {
32
+ const lower = url.toLowerCase();
33
+ for (const kw of keywords) {
34
+ if (kw && lower.includes(kw)) score += 10;
35
+ }
36
+ }
37
+ return score;
38
+ } catch {
39
+ return 0;
40
+ }
41
+ }
42
+
43
+ export class CrawlFrontier {
44
+ private items: FrontierEntry[] = [];
45
+ private readonly strategy: TraversalStrategy;
46
+ private readonly keywords: string[];
47
+ private readonly urlFilter: RegExp | null;
48
+
49
+ constructor(strategy: TraversalStrategy, seedUrls: string[], keywords: string[] = [], urlFilter?: string) {
50
+ this.strategy = strategy;
51
+ this.keywords = keywords.map((k) => k.toLowerCase()).filter(Boolean);
52
+ this.urlFilter = urlFilter
53
+ ? (() => { try { return new RegExp(urlFilter, 'i'); } catch { return null; } })()
54
+ : null;
55
+ for (const url of seedUrls) this.push(url);
56
+ }
57
+
58
+ get length(): number {
59
+ return this.items.length;
60
+ }
61
+
62
+ /**
63
+ * P3b: sparse-parent boost — if the parent page had fewer than 5 interactive
64
+ * elements (thin/canvas page), its child URLs score +1 in BEST_FIRST mode so
65
+ * the frontier revisits sparse branches before deep-linked content.
66
+ */
67
+ push(url: string, pushOpts?: { parentElements?: number }): void {
68
+ if (this.urlFilter && !this.urlFilter.test(url)) return;
69
+ const sparseBoost =
70
+ this.strategy === 'BEST_FIRST' &&
71
+ pushOpts?.parentElements !== undefined &&
72
+ pushOpts.parentElements < 5
73
+ ? 1
74
+ : 0;
75
+ this.items.push({ url, score: this.strategy === 'BEST_FIRST' ? scoreUrl(url, this.keywords) + sparseBoost : 0 });
76
+ }
77
+
78
+ /** Dequeues the next URL per this frontier's strategy. undefined if empty. */
79
+ shift(): string | undefined {
80
+ if (this.items.length === 0) return undefined;
81
+ switch (this.strategy) {
82
+ case 'DFS':
83
+ return this.items.pop()!.url;
84
+ case 'BEST_FIRST': {
85
+ let bestIdx = 0;
86
+ for (let i = 1; i < this.items.length; i++) {
87
+ if (this.items[i].score > this.items[bestIdx].score) bestIdx = i;
88
+ }
89
+ return this.items.splice(bestIdx, 1)[0].url;
90
+ }
91
+ case 'BFS':
92
+ default:
93
+ return this.items.shift()!.url;
94
+ }
95
+ }
96
+
97
+ filter(predicate: (url: string) => boolean): string[] {
98
+ return this.items.filter((e) => predicate(e.url)).map((e) => e.url);
99
+ }
100
+
101
+ /** Remove queued URLs not matching the regex pattern. Returns count removed. */
102
+ filterByPattern(pattern: string): number {
103
+ try {
104
+ const re = new RegExp(pattern, 'i');
105
+ const before = this.items.length;
106
+ this.items = this.items.filter((e) => re.test(e.url));
107
+ return before - this.items.length;
108
+ } catch {
109
+ return 0;
110
+ }
111
+ }
112
+ }
@@ -0,0 +1,122 @@
1
+ /**
2
+ * P6: Gap Pass — LLM-guided second crawl pass
3
+ *
4
+ * After the primary BFS crawl completes, this module:
5
+ * 1. Sends the discovered screen list to an LLM for gap analysis
6
+ * 2. HEAD-validates each suggested URL
7
+ * 3. Returns confirmed gap URLs for targeted re-crawl
8
+ *
9
+ * The caller (crawler.ts) is responsible for actually visiting the confirmed
10
+ * URLs with the existing browser — this module only identifies them.
11
+ */
12
+
13
+ import { logger as rootLogger } from './logger.js';
14
+
15
+ const logger = rootLogger.child({ module: 'gap-pass' });
16
+
17
+ export interface GapCandidate {
18
+ category: string;
19
+ urlPatterns: string[];
20
+ confidence: number;
21
+ rationale: string;
22
+ confirmedUrl: string | null;
23
+ headStatus: number | null;
24
+ }
25
+
26
+ interface DiscoveredScreen {
27
+ url: string;
28
+ name?: string;
29
+ elements?: number;
30
+ }
31
+
32
+ interface LlmCfg {
33
+ provider: string;
34
+ model: string;
35
+ apiKey?: string;
36
+ }
37
+
38
+ /**
39
+ * Run LLM gap analysis + HEAD validation.
40
+ * Returns candidates with `confirmedUrl` set for those where HEAD returned 200.
41
+ */
42
+ export async function runGapAnalysis(
43
+ screens: DiscoveredScreen[],
44
+ appUrl: string,
45
+ llmCfg: LlmCfg | null | undefined,
46
+ ): Promise<GapCandidate[]> {
47
+ if (!llmCfg?.apiKey || screens.length < 2) return [];
48
+
49
+ const origin = (() => { try { return new URL(appUrl).origin; } catch { return ''; } })();
50
+ if (!origin) return [];
51
+
52
+ // Cap context — only send url + name, not full element data
53
+ const screenList = screens
54
+ .slice(0, 80)
55
+ .map(s => {
56
+ try { return new URL(s.url).pathname + (s.name ? ` (${s.name})` : ''); } catch { return s.url; }
57
+ })
58
+ .join('\n');
59
+
60
+ const prompt = `You are analyzing the screen map of a web application to find structural coverage gaps.
61
+
62
+ Discovered screens (${screens.length} total):
63
+ ${screenList}
64
+
65
+ App URL: ${appUrl}
66
+
67
+ Based on these screens and common patterns for this type of web application, identify screen categories that are likely present but missing. Focus on pages that would exist in a typical SaaS or web app but are not represented above.
68
+
69
+ Respond with ONLY valid JSON, no markdown:
70
+ {"gaps":[{"category":"User profile edit","urlPatterns":["/profile/edit","/account/profile"],"confidence":0.8,"rationale":"Profile view found but no edit page"},{"category":"Billing history","urlPatterns":["/settings/billing","/account/billing"],"confidence":0.7,"rationale":"Settings section found but no billing page"}]}`;
71
+
72
+ try {
73
+ const Anthropic = (await import('@anthropic-ai/sdk')).default;
74
+ const client = new Anthropic({ apiKey: llmCfg.apiKey });
75
+ const response = await client.messages.create({
76
+ model: llmCfg.model ?? 'claude-haiku-4-5-20251001',
77
+ max_tokens: 1024,
78
+ // @ts-ignore
79
+ system: [{ type: 'text', text: 'You are a web application coverage analyst. Respond with valid JSON only.', cache_control: { type: 'ephemeral' } }],
80
+ messages: [{ role: 'user', content: prompt }],
81
+ });
82
+
83
+ const text = (response.content[0] as any)?.text ?? '';
84
+ const jsonMatch = text.match(/\{[\s\S]*\}/);
85
+ if (!jsonMatch) return [];
86
+ const parsed = JSON.parse(jsonMatch[0]);
87
+ const rawGaps: Array<{ category: string; urlPatterns: string[]; confidence: number; rationale: string }> =
88
+ parsed.gaps ?? [];
89
+
90
+ if (!Array.isArray(rawGaps) || rawGaps.length === 0) return [];
91
+ logger.info({ gaps: rawGaps.length }, '[P6] Gap analysis: LLM returned candidates');
92
+
93
+ // HEAD-validate each URL pattern, take the first 200
94
+ const results: GapCandidate[] = [];
95
+ for (const gap of rawGaps.slice(0, 15)) {
96
+ let confirmedUrl: string | null = null;
97
+ let headStatus: number | null = null;
98
+
99
+ for (const pattern of (gap.urlPatterns ?? []).slice(0, 4)) {
100
+ const fullUrl = pattern.startsWith('http') ? pattern : `${origin}${pattern.startsWith('/') ? '' : '/'}${pattern}`;
101
+ try {
102
+ const controller = new AbortController();
103
+ const timeout = setTimeout(() => controller.abort(), 6_000);
104
+ const res = await fetch(fullUrl, { method: 'HEAD', signal: controller.signal, redirect: 'follow' }).catch(() => null);
105
+ clearTimeout(timeout);
106
+ headStatus = res?.status ?? null;
107
+ if (res?.ok) { confirmedUrl = fullUrl; break; }
108
+ } catch { /* non-fatal */ }
109
+ }
110
+
111
+ results.push({ category: gap.category, urlPatterns: gap.urlPatterns, confidence: gap.confidence, rationale: gap.rationale, confirmedUrl, headStatus });
112
+ logger.info({ category: gap.category, confirmedUrl, headStatus }, '[P6] Gap candidate checked');
113
+ }
114
+
115
+ const confirmed = results.filter(r => r.confirmedUrl).length;
116
+ logger.info({ total: results.length, confirmed }, '[P6] Gap pass analysis complete');
117
+ return results;
118
+ } catch (err) {
119
+ logger.warn({ err: String(err) }, '[P6] Gap analysis failed — non-fatal');
120
+ return [];
121
+ }
122
+ }
@@ -0,0 +1,129 @@
1
+ /**
2
+ * GraphQL detection + introspection.
3
+ *
4
+ * During crawl, if a GraphQL endpoint is discovered (via network observer or
5
+ * known-URL heuristics), fire a minimal introspection query to get the schema.
6
+ * The schema is stored in the crawl artifact for use during test generation.
7
+ */
8
+
9
+ export interface GqlType {
10
+ name: string;
11
+ kind: string;
12
+ fields?: Array<{ name: string; type: string }>;
13
+ inputFields?: Array<{ name: string; type: string }>;
14
+ enumValues?: string[];
15
+ }
16
+
17
+ export interface GqlSchema {
18
+ queryType?: string;
19
+ mutationType?: string;
20
+ subscriptionType?: string;
21
+ types: GqlType[];
22
+ }
23
+
24
+ export interface GqlIntrospectionResult {
25
+ endpoint: string;
26
+ schema: GqlSchema | null;
27
+ error?: string;
28
+ durationMs: number;
29
+ }
30
+
31
+ const INTROSPECTION_QUERY = `
32
+ query IntrospectionQuery {
33
+ __schema {
34
+ queryType { name }
35
+ mutationType { name }
36
+ subscriptionType { name }
37
+ types {
38
+ name
39
+ kind
40
+ fields(includeDeprecated: false) { name type { name kind ofType { name kind } } }
41
+ inputFields { name type { name kind ofType { name kind } } }
42
+ enumValues(includeDeprecated: false) { name }
43
+ }
44
+ }
45
+ }
46
+ `;
47
+
48
+ function flattenType(t: any): string {
49
+ if (!t) return 'unknown';
50
+ if (t.kind === 'NON_NULL' || t.kind === 'LIST') return flattenType(t.ofType);
51
+ return t.name ?? 'unknown';
52
+ }
53
+
54
+ function parseSchema(raw: any): GqlSchema {
55
+ const s = raw?.__schema ?? raw?.data?.__schema ?? raw;
56
+ const types: GqlType[] = (s?.types ?? [])
57
+ .filter((t: any) => !t.name?.startsWith('__'))
58
+ .map((t: any) => ({
59
+ name: t.name,
60
+ kind: t.kind,
61
+ fields: (t.fields ?? []).map((f: any) => ({ name: f.name, type: flattenType(f.type) })),
62
+ inputFields: (t.inputFields ?? []).map((f: any) => ({ name: f.name, type: flattenType(f.type) })),
63
+ enumValues: (t.enumValues ?? []).map((e: any) => e.name),
64
+ }));
65
+ return {
66
+ queryType: s?.queryType?.name,
67
+ mutationType: s?.mutationType?.name,
68
+ subscriptionType: s?.subscriptionType?.name,
69
+ types,
70
+ };
71
+ }
72
+
73
+ export async function introspectGraphQL(
74
+ endpoint: string,
75
+ opts: {
76
+ headers?: Record<string, string>;
77
+ timeoutMs?: number;
78
+ cookies?: string;
79
+ } = {},
80
+ ): Promise<GqlIntrospectionResult> {
81
+ const { headers = {}, timeoutMs = 10_000, cookies } = opts;
82
+ const t0 = Date.now();
83
+ try {
84
+ const reqHeaders: Record<string, string> = {
85
+ 'Content-Type': 'application/json',
86
+ 'Accept': 'application/json',
87
+ ...headers,
88
+ };
89
+ if (cookies) reqHeaders['Cookie'] = cookies;
90
+
91
+ const res = await fetch(endpoint, {
92
+ method: 'POST',
93
+ headers: reqHeaders,
94
+ body: JSON.stringify({ query: INTROSPECTION_QUERY }),
95
+ signal: AbortSignal.timeout(timeoutMs),
96
+ });
97
+
98
+ if (!res.ok) {
99
+ return { endpoint, schema: null, error: `HTTP ${res.status}`, durationMs: Date.now() - t0 };
100
+ }
101
+
102
+ const json = await res.json();
103
+ if (json?.errors?.length) {
104
+ const msg = json.errors[0]?.message ?? 'introspection disabled';
105
+ return { endpoint, schema: null, error: msg, durationMs: Date.now() - t0 };
106
+ }
107
+
108
+ return { endpoint, schema: parseSchema(json?.data ?? json), durationMs: Date.now() - t0 };
109
+ } catch (e: any) {
110
+ return { endpoint, schema: null, error: e.message, durationMs: Date.now() - t0 };
111
+ }
112
+ }
113
+
114
+ /** Guess GraphQL endpoint URLs from a known app URL. */
115
+ export function guessGraphQLEndpoints(appUrl: string): string[] {
116
+ try {
117
+ const u = new URL(appUrl);
118
+ const base = `${u.protocol}//${u.host}`;
119
+ return [
120
+ `${base}/graphql`,
121
+ `${base}/api/graphql`,
122
+ `${base}/v1/graphql`,
123
+ `${base}/gql`,
124
+ `${base}/query`,
125
+ ];
126
+ } catch {
127
+ return [];
128
+ }
129
+ }