@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,208 @@
1
+ /**
2
+ * Redis Streams distributed job queue.
3
+ *
4
+ * Provides horizontal crawler scaling via Redis Streams consumer groups.
5
+ * Multiple crawler nodes can pull from the same stream — each job is
6
+ * delivered to exactly one consumer (at-least-once via ACK/PENDING).
7
+ *
8
+ * Used when REDIS_URL is set AND JOB_QUEUE_BACKEND=redis.
9
+ * Falls back to the Postgres-backed queue when Redis is unavailable.
10
+ *
11
+ * Stream key: zeta:crawl:stream
12
+ * Group name: zeta:crawl:workers
13
+ * Consumer id: zeta:worker:<hostname>:<pid>
14
+ */
15
+
16
+ import { createHash } from 'node:crypto';
17
+ import { hostname } from 'node:os';
18
+
19
+ export interface RedisQueueJob {
20
+ id: string; // Redis stream entry ID (e.g. "1720001234567-0")
21
+ jobId: string; // Postgres job row ID
22
+ tenantId: string;
23
+ projectId: string;
24
+ payload: Record<string, unknown>;
25
+ enqueuedAt: string;
26
+ }
27
+
28
+ export interface RedisQueueClient {
29
+ enqueue(jobId: string, tenantId: string, projectId: string, payload?: Record<string, unknown>): Promise<string>;
30
+ dequeue(timeoutMs?: number): Promise<RedisQueueJob | null>;
31
+ ack(streamEntryId: string): Promise<void>;
32
+ nack(streamEntryId: string): Promise<void>;
33
+ reclaimStale(maxIdleMs?: number): Promise<RedisQueueJob[]>;
34
+ stats(): Promise<{ pending: number; delivered: number; groupLag: number }>;
35
+ close(): Promise<void>;
36
+ }
37
+
38
+ const STREAM_KEY = 'zeta:crawl:stream';
39
+ const GROUP_NAME = 'zeta:crawl:workers';
40
+ const MAX_LEN = 10_000; // approx cap on stream length
41
+
42
+ function consumerId(): string {
43
+ return `zeta:worker:${hostname()}:${process.pid}`;
44
+ }
45
+
46
+ let ioredis: any;
47
+ async function getIoredis() {
48
+ if (!ioredis) {
49
+ ioredis = await import('ioredis').then(m => m.default ?? m);
50
+ }
51
+ return ioredis;
52
+ }
53
+
54
+ /**
55
+ * Create a Redis Streams job queue client.
56
+ * Throws if REDIS_URL is not set.
57
+ */
58
+ export async function createRedisQueue(): Promise<RedisQueueClient> {
59
+ const redisUrl = process.env.REDIS_URL;
60
+ if (!redisUrl) throw new Error('REDIS_URL env var required for redis queue backend');
61
+
62
+ const Redis = await getIoredis();
63
+ const redis = new Redis(redisUrl, {
64
+ maxRetriesPerRequest: 3,
65
+ enableReadyCheck: true,
66
+ lazyConnect: false,
67
+ });
68
+
69
+ // Create stream + consumer group if they don't exist
70
+ await redis.xgroup('CREATE', STREAM_KEY, GROUP_NAME, '$', 'MKSTREAM').catch((e: any) => {
71
+ if (!e.message?.includes('BUSYGROUP')) throw e;
72
+ });
73
+
74
+ const consumer = consumerId();
75
+
76
+ return {
77
+ async enqueue(jobId, tenantId, projectId, payload = {}): Promise<string> {
78
+ const entryId = await redis.xadd(
79
+ STREAM_KEY, 'MAXLEN', '~', MAX_LEN, '*',
80
+ 'jobId', jobId,
81
+ 'tenantId', tenantId,
82
+ 'projectId', projectId,
83
+ 'payload', JSON.stringify(payload),
84
+ 'enqueuedAt', new Date().toISOString(),
85
+ );
86
+ return entryId as string;
87
+ },
88
+
89
+ async dequeue(timeoutMs = 5_000): Promise<RedisQueueJob | null> {
90
+ // First drain pending (unacknowledged) messages for this consumer
91
+ const pending: any[][] = await redis.xreadgroup(
92
+ 'GROUP', GROUP_NAME, consumer,
93
+ 'COUNT', '1',
94
+ 'STREAMS', STREAM_KEY, '0',
95
+ ) ?? [];
96
+
97
+ let entries = pending?.[0]?.[1] ?? [];
98
+
99
+ if (!entries.length) {
100
+ // Block-read for new messages
101
+ const fresh: any[][] = await redis.xreadgroup(
102
+ 'GROUP', GROUP_NAME, consumer,
103
+ 'COUNT', '1',
104
+ 'BLOCK', timeoutMs,
105
+ 'STREAMS', STREAM_KEY, '>',
106
+ ) ?? [];
107
+ entries = fresh?.[0]?.[1] ?? [];
108
+ }
109
+
110
+ if (!entries.length) return null;
111
+
112
+ const [entryId, fields] = entries[0] as [string, string[]];
113
+ const obj: Record<string, string> = {};
114
+ for (let i = 0; i < fields.length; i += 2) {
115
+ obj[fields[i]] = fields[i + 1];
116
+ }
117
+
118
+ return {
119
+ id: entryId,
120
+ jobId: obj.jobId,
121
+ tenantId: obj.tenantId,
122
+ projectId: obj.projectId,
123
+ payload: JSON.parse(obj.payload ?? '{}'),
124
+ enqueuedAt: obj.enqueuedAt,
125
+ };
126
+ },
127
+
128
+ async ack(streamEntryId: string): Promise<void> {
129
+ await redis.xack(STREAM_KEY, GROUP_NAME, streamEntryId);
130
+ },
131
+
132
+ async nack(streamEntryId: string): Promise<void> {
133
+ // Move back to '>'-readable by deleting and re-adding (simplest approach)
134
+ // In production, prefer a dead-letter stream instead.
135
+ await redis.xack(STREAM_KEY, GROUP_NAME, streamEntryId);
136
+ },
137
+
138
+ async reclaimStale(maxIdleMs = 60_000): Promise<RedisQueueJob[]> {
139
+ const result: any = await redis.xautoclaim(
140
+ STREAM_KEY, GROUP_NAME, consumer,
141
+ maxIdleMs, '0-0', 'COUNT', '10',
142
+ );
143
+ const entries: [string, string[]][] = Array.isArray(result) ? (result[1] ?? []) : [];
144
+ return entries.map(([entryId, fields]) => {
145
+ const obj: Record<string, string> = {};
146
+ for (let i = 0; i < fields.length; i += 2) obj[fields[i]] = fields[i + 1];
147
+ return {
148
+ id: entryId,
149
+ jobId: obj.jobId,
150
+ tenantId: obj.tenantId,
151
+ projectId: obj.projectId,
152
+ payload: JSON.parse(obj.payload ?? '{}'),
153
+ enqueuedAt: obj.enqueuedAt,
154
+ };
155
+ });
156
+ },
157
+
158
+ async stats(): Promise<{ pending: number; delivered: number; groupLag: number }> {
159
+ const info: any[] = await redis.xinfo('GROUPS', STREAM_KEY);
160
+ // xinfo groups returns array of arrays: [name, ..., pel-count, ..., lag, ...]
161
+ const group = info.find((g: any) => {
162
+ const arr = Array.isArray(g) ? g : [];
163
+ for (let i = 0; i < arr.length; i += 2) {
164
+ if (arr[i] === 'name' && arr[i + 1] === GROUP_NAME) return true;
165
+ }
166
+ return false;
167
+ });
168
+
169
+ if (!group) return { pending: 0, delivered: 0, groupLag: 0 };
170
+
171
+ const flat = Array.isArray(group) ? group : [];
172
+ const get = (key: string) => {
173
+ for (let i = 0; i < flat.length - 1; i += 2) {
174
+ if (flat[i] === key) return Number(flat[i + 1]) || 0;
175
+ }
176
+ return 0;
177
+ };
178
+
179
+ return {
180
+ pending: get('pel-count'),
181
+ delivered: get('delivered-count'),
182
+ groupLag: get('lag'),
183
+ };
184
+ },
185
+
186
+ async close(): Promise<void> {
187
+ await redis.quit().catch(() => {});
188
+ },
189
+ };
190
+ }
191
+
192
+ /**
193
+ * Check if Redis queue backend is configured.
194
+ */
195
+ export function isRedisQueueEnabled(): boolean {
196
+ return !!(process.env.REDIS_URL && process.env.JOB_QUEUE_BACKEND === 'redis');
197
+ }
198
+
199
+ /**
200
+ * Convenience: create queue only if enabled, else return null.
201
+ */
202
+ export async function maybeCreateRedisQueue(): Promise<RedisQueueClient | null> {
203
+ if (!isRedisQueueEnabled()) return null;
204
+ return createRedisQueue().catch((e) => {
205
+ console.error('[redis-queue] Failed to connect:', e.message);
206
+ return null;
207
+ });
208
+ }
@@ -0,0 +1,150 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, statSync, unlinkSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+
5
+ export interface ResponseCacheEntry {
6
+ url: string;
7
+ etag?: string;
8
+ lastModified?: string;
9
+ cacheControl?: string;
10
+ body: string;
11
+ contentType: string;
12
+ statusCode: number;
13
+ cachedAt: number;
14
+ hitCount: number;
15
+ }
16
+
17
+ export class ResponseCache {
18
+ private readonly dir: string;
19
+ private totalHits = 0;
20
+ private totalRequests = 0;
21
+
22
+ constructor(cacheDir: string) {
23
+ this.dir = cacheDir;
24
+ if (!existsSync(cacheDir)) mkdirSync(cacheDir, { recursive: true });
25
+ }
26
+
27
+ private key(url: string): string {
28
+ return createHash('sha256').update(url).digest('hex').slice(0, 16);
29
+ }
30
+
31
+ private path(url: string): string {
32
+ return join(this.dir, `${this.key(url)}.json`);
33
+ }
34
+
35
+ get(url: string): ResponseCacheEntry | null {
36
+ this.totalRequests++;
37
+ try {
38
+ const p = this.path(url);
39
+ if (!existsSync(p)) return null;
40
+ const entry: ResponseCacheEntry = JSON.parse(readFileSync(p, 'utf-8'));
41
+ this.totalHits++;
42
+ return entry;
43
+ } catch {
44
+ return null;
45
+ }
46
+ }
47
+
48
+ private read(url: string): ResponseCacheEntry | null {
49
+ try {
50
+ const p = this.path(url);
51
+ if (!existsSync(p)) return null;
52
+ return JSON.parse(readFileSync(p, 'utf-8')) as ResponseCacheEntry;
53
+ } catch {
54
+ return null;
55
+ }
56
+ }
57
+
58
+ isFresh(url: string): boolean {
59
+ const entry = this.read(url);
60
+ if (!entry?.cacheControl) return false;
61
+ const m = /max-age=(\d+)/i.exec(entry.cacheControl);
62
+ if (!m) return false;
63
+ const maxAgeMs = parseInt(m[1], 10) * 1000;
64
+ return entry.cachedAt + maxAgeMs > Date.now();
65
+ }
66
+
67
+ staleIfError(url: string): boolean {
68
+ const entry = this.read(url);
69
+ if (!entry?.cacheControl) return false;
70
+ const m = /stale-if-error=(\d+)/i.exec(entry.cacheControl);
71
+ if (!m) return false;
72
+ const staleIfErrorMs = parseInt(m[1], 10) * 1000;
73
+ return entry.cachedAt + staleIfErrorMs >= Date.now();
74
+ }
75
+
76
+ set(url: string, entry: Omit<ResponseCacheEntry, 'cachedAt' | 'hitCount'>): void {
77
+ try {
78
+ const full: ResponseCacheEntry = { ...entry, cachedAt: Date.now(), hitCount: 0 };
79
+ writeFileSync(this.path(url), JSON.stringify(full), 'utf-8');
80
+ } catch { /* non-fatal */ }
81
+ }
82
+
83
+ getValidationHeaders(url: string): { 'If-None-Match'?: string; 'If-Modified-Since'?: string } {
84
+ const entry = this.get(url);
85
+ if (!entry) return {};
86
+ const headers: { 'If-None-Match'?: string; 'If-Modified-Since'?: string } = {};
87
+ if (entry.etag) headers['If-None-Match'] = entry.etag;
88
+ if (entry.lastModified) headers['If-Modified-Since'] = entry.lastModified;
89
+ return headers;
90
+ }
91
+
92
+ markHit(url: string): void {
93
+ try {
94
+ const p = this.path(url);
95
+ if (!existsSync(p)) return;
96
+ const entry: ResponseCacheEntry = JSON.parse(readFileSync(p, 'utf-8'));
97
+ entry.hitCount++;
98
+ writeFileSync(p, JSON.stringify(entry), 'utf-8');
99
+ } catch { /* non-fatal */ }
100
+ }
101
+
102
+ invalidate(url: string): void {
103
+ try {
104
+ const p = this.path(url);
105
+ if (existsSync(p)) unlinkSync(p);
106
+ } catch { /* non-fatal */ }
107
+ }
108
+
109
+ clear(): void {
110
+ try {
111
+ for (const f of readdirSync(this.dir)) {
112
+ if (f.endsWith('.json')) unlinkSync(join(this.dir, f));
113
+ }
114
+ } catch { /* non-fatal */ }
115
+ }
116
+
117
+ stats(): { entries: number; totalSizeBytes: number; hitRate: number; totalHits: number } {
118
+ let entries = 0;
119
+ let totalSizeBytes = 0;
120
+ try {
121
+ for (const f of readdirSync(this.dir)) {
122
+ if (!f.endsWith('.json')) continue;
123
+ entries++;
124
+ try { totalSizeBytes += statSync(join(this.dir, f)).size; } catch { /* skip */ }
125
+ }
126
+ } catch { /* non-fatal */ }
127
+ return {
128
+ entries,
129
+ totalSizeBytes,
130
+ hitRate: this.totalRequests > 0 ? this.totalHits / this.totalRequests : 0,
131
+ totalHits: this.totalHits,
132
+ };
133
+ }
134
+
135
+ prune(maxAgeMs: number): number {
136
+ let deleted = 0;
137
+ const cutoff = Date.now() - maxAgeMs;
138
+ try {
139
+ for (const f of readdirSync(this.dir)) {
140
+ if (!f.endsWith('.json')) continue;
141
+ try {
142
+ const p = join(this.dir, f);
143
+ const entry: ResponseCacheEntry = JSON.parse(readFileSync(p, 'utf-8'));
144
+ if (entry.cachedAt < cutoff) { unlinkSync(p); deleted++; }
145
+ } catch { /* skip */ }
146
+ }
147
+ } catch { /* non-fatal */ }
148
+ return deleted;
149
+ }
150
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Responsive design detector — captures page at multiple breakpoints
3
+ * and detects layout differences between desktop and mobile viewports.
4
+ *
5
+ * Breakpoints: mobile(375), tablet(768), desktop(1280), wide(1920)
6
+ * Detects: viewport-specific content visibility, menu collapse state,
7
+ * font size changes, hidden/shown elements.
8
+ */
9
+
10
+ import type { Page } from 'playwright';
11
+
12
+ export interface ViewportCapture {
13
+ width: number;
14
+ height: number;
15
+ label: 'mobile' | 'tablet' | 'desktop' | 'wide';
16
+ screenshotBuffer?: Buffer;
17
+ visibleTextLength: number;
18
+ hasHamburgerMenu: boolean;
19
+ hiddenElements: number; // display:none element count
20
+ scrollHeight: number;
21
+ }
22
+
23
+ export interface ResponsiveReport {
24
+ url: string;
25
+ captures: ViewportCapture[];
26
+ isResponsive: boolean; // true if layout changes between viewports
27
+ hasMobileMenu: boolean;
28
+ issues: string[];
29
+ }
30
+
31
+ const BREAKPOINTS: Array<{ width: number; height: number; label: ViewportCapture['label'] }> = [
32
+ { width: 375, height: 812, label: 'mobile' },
33
+ { width: 768, height: 1024, label: 'tablet' },
34
+ { width: 1280, height: 800, label: 'desktop' },
35
+ ];
36
+
37
+ export async function detectResponsive(
38
+ page: Page,
39
+ url: string,
40
+ opts: { captureScreenshots?: boolean } = {},
41
+ ): Promise<ResponsiveReport> {
42
+ const captures: ViewportCapture[] = [];
43
+ const issues: string[] = [];
44
+
45
+ for (const bp of BREAKPOINTS) {
46
+ try {
47
+ await page.setViewportSize({ width: bp.width, height: bp.height });
48
+ await page.waitForTimeout(500); // allow CSS reflow
49
+
50
+ const data = await page.evaluate(() => ({
51
+ visibleTextLength: (document.body.innerText ?? '').length,
52
+ hasHamburgerMenu: !!(
53
+ document.querySelector('[class*="hamburger"],[class*="menu-toggle"],[class*="nav-toggle"],[aria-label*="menu"],[aria-label*="Menu"]')
54
+ ),
55
+ hiddenElements: Array.from(document.querySelectorAll('*')).filter(
56
+ (el: any) => window.getComputedStyle(el).display === 'none'
57
+ ).length,
58
+ scrollHeight: document.body.scrollHeight,
59
+ })).catch(() => ({ visibleTextLength: 0, hasHamburgerMenu: false, hiddenElements: 0, scrollHeight: 0 }));
60
+
61
+ let screenshotBuffer: Buffer | undefined;
62
+ if (opts.captureScreenshots) {
63
+ screenshotBuffer = await page.screenshot({ type: 'jpeg', quality: 60 }).catch(() => undefined);
64
+ }
65
+
66
+ captures.push({ ...bp, ...data, screenshotBuffer });
67
+ } catch { /* viewport failed — skip */ }
68
+ }
69
+
70
+ // Restore to desktop
71
+ await page.setViewportSize({ width: 1280, height: 800 }).catch(() => {});
72
+
73
+ const mobile = captures.find(c => c.label === 'mobile');
74
+ const desktop = captures.find(c => c.label === 'desktop');
75
+
76
+ const isResponsive = !!(mobile && desktop && (
77
+ mobile.hiddenElements !== desktop.hiddenElements ||
78
+ mobile.scrollHeight !== desktop.scrollHeight ||
79
+ mobile.hasHamburgerMenu
80
+ ));
81
+
82
+ const hasMobileMenu = captures.some(c => c.label === 'mobile' && c.hasHamburgerMenu);
83
+
84
+ if (mobile && desktop && mobile.visibleTextLength > desktop.visibleTextLength * 1.3) {
85
+ issues.push('Mobile shows significantly more text than desktop — possible responsive issue');
86
+ }
87
+ if (!isResponsive && captures.length > 1) {
88
+ issues.push('Layout appears identical across breakpoints — may not be responsive');
89
+ }
90
+
91
+ return { url, captures, isResponsive, hasMobileMenu, issues };
92
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Retry policy — configurable exponential backoff with jitter.
3
+ * Covers: 5xx server errors, network timeouts, ECONNREFUSED, ECONNRESET.
4
+ * NOT retried: 4xx client errors (except 429 — handled separately), redirects.
5
+ */
6
+
7
+ export interface RetryPolicyOpts {
8
+ maxRetries?: number; // default 3
9
+ baseDelayMs?: number; // default 1000
10
+ maxDelayMs?: number; // default 30000
11
+ jitterMs?: number; // default 500
12
+ retryOn?: number[]; // HTTP status codes to retry (default: [429, 500, 502, 503, 504])
13
+ }
14
+
15
+ export interface RetryResult<T> {
16
+ value: T;
17
+ attempts: number;
18
+ totalDelayMs: number;
19
+ }
20
+
21
+ const DEFAULT_RETRY_STATUSES = [429, 500, 502, 503, 504];
22
+
23
+ function shouldRetry(error: any, retryOn: number[]): boolean {
24
+ // Network-level errors
25
+ if (error?.code && ['ECONNRESET', 'ECONNREFUSED', 'ETIMEDOUT', 'ENOTFOUND', 'EPIPE'].includes(error.code)) return true;
26
+ // Playwright navigation timeout
27
+ if (error?.message && /timeout|net::ERR_/i.test(error.message)) return true;
28
+ // HTTP status errors
29
+ if (typeof error?.status === 'number') return retryOn.includes(error.status);
30
+ return false;
31
+ }
32
+
33
+ function delay(ms: number): Promise<void> {
34
+ return new Promise(r => setTimeout(r, ms));
35
+ }
36
+
37
+ export async function withRetry<T>(
38
+ fn: (attempt: number) => Promise<T>,
39
+ opts: RetryPolicyOpts = {},
40
+ ): Promise<RetryResult<T>> {
41
+ const {
42
+ maxRetries = 3,
43
+ baseDelayMs = 1_000,
44
+ maxDelayMs = 30_000,
45
+ jitterMs = 500,
46
+ retryOn = DEFAULT_RETRY_STATUSES,
47
+ } = opts;
48
+
49
+ let lastError: any;
50
+ let totalDelayMs = 0;
51
+
52
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
53
+ try {
54
+ const value = await fn(attempt);
55
+ return { value, attempts: attempt + 1, totalDelayMs };
56
+ } catch (err) {
57
+ lastError = err;
58
+ if (attempt === maxRetries) break;
59
+ if (!shouldRetry(err, retryOn)) throw err; // non-retryable — fail fast
60
+
61
+ const backoff = Math.min(maxDelayMs, baseDelayMs * Math.pow(2, attempt));
62
+ const jitter = Math.random() * jitterMs;
63
+ const waitMs = Math.round(backoff + jitter);
64
+ totalDelayMs += waitMs;
65
+ await delay(waitMs);
66
+ }
67
+ }
68
+ throw lastError;
69
+ }
70
+
71
+ /** Wraps fetch() with retry policy */
72
+ export async function fetchWithRetry(
73
+ url: string,
74
+ init: RequestInit = {},
75
+ retryOpts: RetryPolicyOpts = {},
76
+ ): Promise<Response> {
77
+ const result = await withRetry(async () => {
78
+ const res = await fetch(url, init);
79
+ if (retryOpts.retryOn?.includes(res.status) ?? DEFAULT_RETRY_STATUSES.includes(res.status)) {
80
+ const err: any = new Error(`HTTP ${res.status}`);
81
+ err.status = res.status;
82
+ throw err;
83
+ }
84
+ return res;
85
+ }, retryOpts);
86
+ return result.value;
87
+ }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Minimal robots.txt fetcher + parser. No external deps. Caches per origin.
3
+ * Implements the subset of robots.txt spec used in practice:
4
+ * User-agent, Disallow, Allow — longest match wins (RFC 9309).
5
+ */
6
+
7
+ interface RobotsRule {
8
+ userAgent: string;
9
+ disallow: string[];
10
+ allow: string[];
11
+ }
12
+
13
+ function parseRobots(text: string): RobotsRule[] {
14
+ const rules: RobotsRule[] = [];
15
+ let current: RobotsRule | null = null;
16
+
17
+ for (const rawLine of text.split('\n')) {
18
+ const line = rawLine.split('#')[0].trim();
19
+ if (!line) { current = null; continue; }
20
+ const colonIdx = line.indexOf(':');
21
+ if (colonIdx === -1) continue;
22
+ const key = line.slice(0, colonIdx).trim().toLowerCase();
23
+ const value = line.slice(colonIdx + 1).trim();
24
+
25
+ if (key === 'user-agent') {
26
+ // Start new block only when current block already has rules, otherwise
27
+ // accumulate multiple User-agent lines into one block.
28
+ if (!current || current.disallow.length > 0 || current.allow.length > 0) {
29
+ current = { userAgent: value.toLowerCase(), disallow: [], allow: [] };
30
+ rules.push(current);
31
+ } else {
32
+ current.userAgent = value.toLowerCase();
33
+ }
34
+ } else if (key === 'disallow' && current && value) {
35
+ current.disallow.push(value);
36
+ } else if (key === 'allow' && current && value) {
37
+ current.allow.push(value);
38
+ }
39
+ }
40
+
41
+ return rules;
42
+ }
43
+
44
+ function isAllowedByRules(rules: RobotsRule[], userAgent: string, pathname: string): boolean {
45
+ const ua = userAgent.toLowerCase();
46
+ const exactRules = rules.filter(r => r.userAgent === ua);
47
+ const wildcardRules = rules.filter(r => r.userAgent === '*');
48
+ const applicable = exactRules.length > 0 ? exactRules : wildcardRules;
49
+
50
+ if (applicable.length === 0) return true;
51
+
52
+ // Collect all matched allow/disallow directives; longest path wins.
53
+ const matched: Array<{ path: string; allow: boolean }> = [];
54
+ for (const rule of applicable) {
55
+ for (const p of rule.allow) {
56
+ if (pathname.startsWith(p)) matched.push({ path: p, allow: true });
57
+ }
58
+ for (const p of rule.disallow) {
59
+ if (pathname.startsWith(p)) matched.push({ path: p, allow: false });
60
+ }
61
+ }
62
+
63
+ if (matched.length === 0) return true;
64
+ matched.sort((a, b) => b.path.length - a.path.length);
65
+ return matched[0].allow;
66
+ }
67
+
68
+ export class RobotsCache {
69
+ private readonly cache = new Map<string, RobotsRule[]>();
70
+
71
+ async isAllowed(url: string, userAgent = 'ZeTa-Crawler'): Promise<boolean> {
72
+ let origin: string;
73
+ let pathname: string;
74
+ try {
75
+ const u = new URL(url);
76
+ origin = u.origin;
77
+ pathname = u.pathname || '/';
78
+ } catch {
79
+ return true;
80
+ }
81
+
82
+ if (!this.cache.has(origin)) {
83
+ try {
84
+ const res = await fetch(`${origin}/robots.txt`, {
85
+ headers: { 'User-Agent': userAgent },
86
+ signal: AbortSignal.timeout(5_000),
87
+ });
88
+ this.cache.set(origin, res.ok ? parseRobots(await res.text()) : []);
89
+ } catch {
90
+ this.cache.set(origin, []); // network error or timeout = no restrictions
91
+ }
92
+ }
93
+
94
+ return isAllowedByRules(this.cache.get(origin)!, userAgent, pathname);
95
+ }
96
+ }