@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,204 @@
1
+ import type { BrowserContext } from 'playwright';
2
+ import { redactJson } from './policies/crawl-safety.js';
3
+
4
+ export interface ApiEndpointObservationInput {
5
+ method: string;
6
+ urlPattern: string;
7
+ statusCode?: number;
8
+ requestKind?: string;
9
+ responseShape?: unknown;
10
+ durationMs?: number;
11
+ errorBody?: string;
12
+ }
13
+
14
+ const API_PATTERN = /\/(api|graphql|gql|rest|v\d+)\//i;
15
+ const ASSET_EXT = /\.(js|css|png|jpg|jpeg|gif|svg|ico|woff|woff2|ttf|eot|map|json\.map)(\?|$)/i;
16
+
17
+ function normalizeApiUrl(url: string): string {
18
+ const u = new URL(url);
19
+ return u.pathname
20
+ .replace(/\/[a-f0-9-]{8,}(?=\/|$)/gi, '/{id}')
21
+ .replace(/\/c[a-z0-9]{20,}(?=\/|$)/g, '/{id}')
22
+ .replace(/\/\d+(?=\/|$)/g, '/{id}');
23
+ }
24
+
25
+ function inferShape(value: unknown): unknown {
26
+ if (Array.isArray(value)) return value.length > 0 ? [inferShape(value[0])] : [];
27
+ if (value && typeof value === 'object') {
28
+ const out: Record<string, unknown> = {};
29
+ for (const [key, item] of Object.entries(value).slice(0, 40)) out[key] = inferShape(item);
30
+ return out;
31
+ }
32
+ return typeof value;
33
+ }
34
+
35
+ /** P4: Extract page-URL candidates from a JSON response body string. */
36
+ function extractPageUrls(jsonStr: string): string[] {
37
+ const PAGE_URL = /"(?:url|href|path|link|route|to|src|redirect)"\s*:\s*"(\/[\w\-\/\.~@%]+)"/g;
38
+ const ASSET = /\.(png|jpg|jpeg|gif|svg|ico|woff|woff2|ttf|css|js|map|json)$/i;
39
+ const API_PATH = /^\/(api|v\d+|graphql|gql|rest|rpc|static|_next|assets)\//i;
40
+ const found = new Set<string>();
41
+ let m: RegExpExecArray | null;
42
+ PAGE_URL.lastIndex = 0;
43
+ while ((m = PAGE_URL.exec(jsonStr)) !== null) {
44
+ const u = m[1];
45
+ if (u && u.length > 1 && u.length < 120 && !ASSET.test(u) && !API_PATH.test(u)) found.add(u);
46
+ }
47
+ return [...found];
48
+ }
49
+
50
+ export function attachNetworkObserver(
51
+ context: BrowserContext,
52
+ sink: ApiEndpointObservationInput[],
53
+ onPageUrlDiscovered?: (relPath: string) => void,
54
+ ) {
55
+ const seen = new Map<string, ApiEndpointObservationInput>();
56
+ const requestTimes = new Map<string, number>();
57
+
58
+ context.on('request', (request) => {
59
+ try {
60
+ const url = request.url();
61
+ const rtype = request.resourceType();
62
+ if ((rtype !== 'xhr' && rtype !== 'fetch') || !API_PATTERN.test(url) || ASSET_EXT.test(url)) return;
63
+ const method = request.method();
64
+ const normalized = normalizeApiUrl(url);
65
+ const key = `${method}:${normalized}`;
66
+ requestTimes.set(url, Date.now());
67
+ if (!seen.has(key)) {
68
+ const row = { method, urlPattern: normalized, requestKind: rtype };
69
+ seen.set(key, row);
70
+ sink.push(row);
71
+ }
72
+ } catch {
73
+ // non-fatal
74
+ }
75
+ });
76
+
77
+ context.on('response', async (response) => {
78
+ try {
79
+ const request = response.request();
80
+ const url = request.url();
81
+ const rtype = request.resourceType();
82
+ if ((rtype !== 'xhr' && rtype !== 'fetch') || !API_PATTERN.test(url) || ASSET_EXT.test(url)) return;
83
+ const normalized = normalizeApiUrl(url);
84
+ const key = `${request.method()}:${normalized}`;
85
+ const durationMs = Date.now() - (requestTimes.get(url) ?? Date.now());
86
+ let row = seen.get(key);
87
+ if (!row) {
88
+ row = { method: request.method(), urlPattern: normalized, requestKind: rtype };
89
+ seen.set(key, row);
90
+ sink.push(row);
91
+ }
92
+ row.statusCode = response.status();
93
+ row.durationMs = durationMs;
94
+ const contentType = response.headers()['content-type'] ?? '';
95
+ if (contentType.includes('application/json')) {
96
+ const body = await response.json().catch(() => undefined);
97
+ if (body !== undefined) {
98
+ row.responseShape = redactJson(inferShape(body));
99
+ // P4: scan JSON body for page-URL candidates (non-API paths)
100
+ if (onPageUrlDiscovered) {
101
+ for (const u of extractPageUrls(JSON.stringify(body))) onPageUrlDiscovered(u);
102
+ }
103
+ }
104
+ }
105
+ if (response.status() >= 400) {
106
+ try {
107
+ const errText = await response.text();
108
+ row.errorBody = errText.slice(0, 300);
109
+ } catch { /* non-fatal */ }
110
+ }
111
+ } catch {
112
+ // non-fatal
113
+ }
114
+ });
115
+ }
116
+
117
+ // ── Full HAR capture ──────────────────────────────────────────────────────────
118
+
119
+ export interface HarEntry {
120
+ startedAt: string;
121
+ method: string;
122
+ url: string;
123
+ status: number;
124
+ statusText: string;
125
+ requestHeaders: Record<string, string>;
126
+ responseHeaders: Record<string, string>;
127
+ contentType: string;
128
+ bodySize: number;
129
+ durationMs: number;
130
+ timings: { send: number; wait: number; receive: number };
131
+ }
132
+
133
+ export interface HarLog {
134
+ version: '1.2';
135
+ creator: { name: 'ZeTa-Crawler'; version: '1.0' };
136
+ pages: Array<{ id: string; title: string; startedAt: string }>;
137
+ entries: HarEntry[];
138
+ }
139
+
140
+ /**
141
+ * Full HAR capture via CDP — intercepts ALL network traffic, not just API endpoints.
142
+ * Returns a controller with getHar() and detach().
143
+ */
144
+ export function attachHarCapture(cdpSession: any, pageTitle = 'Page'): { getHar(): HarLog; detach(): void } {
145
+ const entries: HarEntry[] = [];
146
+ const pending = new Map<string, { method: string; url: string; headers: Record<string, string>; startMs: number }>();
147
+ const pageId = `page_${String(Date.now())}`;
148
+ const pageStarted = new Date().toISOString();
149
+
150
+ const onRequest = (evt: any) => {
151
+ const { requestId, request } = evt ?? {};
152
+ if (!requestId || !request) return;
153
+ const headers: Record<string, string> = {};
154
+ for (const h of (request.headers ?? [])) if (h.name) headers[String(h.name).toLowerCase()] = String(h.value ?? '');
155
+ pending.set(requestId, { method: request.method ?? 'GET', url: request.url ?? '', headers, startMs: Date.now() });
156
+ };
157
+
158
+ const onResponse = (evt: any) => {
159
+ const { requestId, response } = evt ?? {};
160
+ if (!requestId || !response) return;
161
+ const req = pending.get(requestId);
162
+ if (!req) return;
163
+ pending.delete(requestId);
164
+ const durationMs = Date.now() - req.startMs;
165
+ const responseHeaders: Record<string, string> = {};
166
+ for (const h of (response.headers ?? [])) if (h.name) responseHeaders[String(h.name).toLowerCase()] = String(h.value ?? '');
167
+ entries.push({
168
+ startedAt: new Date(req.startMs).toISOString(),
169
+ method: req.method,
170
+ url: req.url,
171
+ status: response.status ?? 0,
172
+ statusText: response.statusText ?? '',
173
+ requestHeaders: req.headers,
174
+ responseHeaders,
175
+ contentType: responseHeaders['content-type'] ?? '',
176
+ bodySize: response.encodedDataLength ?? -1,
177
+ durationMs,
178
+ timings: { send: 0, wait: Math.round(durationMs * 0.9), receive: Math.round(durationMs * 0.1) },
179
+ });
180
+ };
181
+
182
+ if (cdpSession?.on) {
183
+ cdpSession.on('Network.requestWillBeSent', onRequest);
184
+ cdpSession.on('Network.responseReceived', onResponse);
185
+ }
186
+
187
+ return {
188
+ getHar(): HarLog {
189
+ return {
190
+ version: '1.2',
191
+ creator: { name: 'ZeTa-Crawler', version: '1.0' },
192
+ pages: [{ id: pageId, title: pageTitle, startedAt: pageStarted }],
193
+ entries: [...entries],
194
+ };
195
+ },
196
+ detach() {
197
+ if (cdpSession?.off) {
198
+ cdpSession.off('Network.requestWillBeSent', onRequest);
199
+ cdpSession.off('Network.responseReceived', onResponse);
200
+ }
201
+ pending.clear();
202
+ },
203
+ };
204
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Network throttle simulation via CDP.
3
+ *
4
+ * Simulates different network conditions during crawl to test how the app
5
+ * behaves under slow connections. Useful for detecting loading states, error
6
+ * handling, and performance degradation on poor networks.
7
+ *
8
+ * Conditions mirror Chrome DevTools presets:
9
+ * - slow3g: ~500kbps download, high latency
10
+ * - fast3g: ~1.6Mbps download, medium latency
11
+ * - 4g: ~9Mbps download, low latency
12
+ * - offline: no connectivity
13
+ * - none: no throttle (default)
14
+ */
15
+
16
+ export type NetworkConditionPreset = 'none' | 'slow3g' | 'fast3g' | '4g' | 'offline';
17
+
18
+ interface NetworkCondition {
19
+ offline: boolean;
20
+ downloadThroughput: number; // bytes/s
21
+ uploadThroughput: number; // bytes/s
22
+ latency: number; // ms
23
+ }
24
+
25
+ const PRESETS: Record<NetworkConditionPreset, NetworkCondition> = {
26
+ none: { offline: false, downloadThroughput: -1, uploadThroughput: -1, latency: 0 },
27
+ slow3g: { offline: false, downloadThroughput: 500 * 1024 / 8, uploadThroughput: 500 * 1024 / 8, latency: 2_000 },
28
+ fast3g: { offline: false, downloadThroughput: 1.6 * 1024 * 1024 / 8, uploadThroughput: 750 * 1024 / 8, latency: 562.5 },
29
+ '4g': { offline: false, downloadThroughput: 9 * 1024 * 1024 / 8, uploadThroughput: 2.67 * 1024 * 1024 / 8, latency: 85 },
30
+ offline: { offline: true, downloadThroughput: 0, uploadThroughput: 0, latency: 0 },
31
+ };
32
+
33
+ export async function applyNetworkThrottle(
34
+ cdpSession: any,
35
+ preset: NetworkConditionPreset,
36
+ ): Promise<void> {
37
+ if (!cdpSession?.send) return;
38
+ const condition = PRESETS[preset];
39
+ try {
40
+ await cdpSession.send('Network.enable');
41
+ await cdpSession.send('Network.emulateNetworkConditions', condition);
42
+ } catch { /* non-fatal — CDP may not be available */ }
43
+ }
44
+
45
+ export async function clearNetworkThrottle(cdpSession: any): Promise<void> {
46
+ await applyNetworkThrottle(cdpSession, 'none');
47
+ }
48
+
49
+ export interface ThrottledCrawlOpts {
50
+ /** Network preset to apply during this URL's crawl */
51
+ preset: NetworkConditionPreset;
52
+ /** Restore to 'none' after navigation (default: true) */
53
+ restoreAfter?: boolean;
54
+ }
55
+
56
+ /** Apply throttle, run fn, restore. Returns fn's result. */
57
+ export async function withNetworkThrottle<T>(
58
+ cdpSession: any,
59
+ preset: NetworkConditionPreset,
60
+ fn: () => Promise<T>,
61
+ restoreAfter = true,
62
+ ): Promise<T> {
63
+ await applyNetworkThrottle(cdpSession, preset);
64
+ try {
65
+ return await fn();
66
+ } finally {
67
+ if (restoreAfter) await clearNetworkThrottle(cdpSession).catch(() => {});
68
+ }
69
+ }
@@ -0,0 +1,135 @@
1
+ /**
2
+ * OpenAPI 3.0 spec generator from observed network traffic.
3
+ *
4
+ * Takes the ApiEndpointObservationInput[] collected by the network observer
5
+ * during crawl and generates a minimal but valid OpenAPI 3.0 spec.
6
+ *
7
+ * The spec captures: endpoints, methods, observed response shapes, status codes.
8
+ * It does NOT capture request bodies (we'd need to intercept requests too) — that's
9
+ * marked as a TODO. But it's enough for generating smoke-test suites.
10
+ */
11
+
12
+ import type { ApiEndpointObservationInput } from './network-observer.js';
13
+
14
+ export interface OpenApiSpec {
15
+ openapi: '3.0.3';
16
+ info: { title: string; version: string; description?: string };
17
+ servers: Array<{ url: string }>;
18
+ paths: Record<string, Record<string, OpenApiOperation>>;
19
+ }
20
+
21
+ interface OpenApiOperation {
22
+ summary: string;
23
+ parameters?: OpenApiParam[];
24
+ responses: Record<string, { description: string; content?: Record<string, { schema: unknown }> }>;
25
+ tags?: string[];
26
+ }
27
+
28
+ interface OpenApiParam {
29
+ name: string;
30
+ in: 'path' | 'query';
31
+ required: boolean;
32
+ schema: { type: string };
33
+ }
34
+
35
+ function extractPathParams(urlPattern: string): OpenApiParam[] {
36
+ const params: OpenApiParam[] = [];
37
+ const matches = urlPattern.matchAll(/\{(\w+)\}/g);
38
+ for (const m of matches) {
39
+ params.push({ name: m[1], in: 'path', required: true, schema: { type: 'string' } });
40
+ }
41
+ return params;
42
+ }
43
+
44
+ function methodSummary(method: string, path: string): string {
45
+ const resource = path.split('/').filter(s => !s.startsWith('{') && s.length > 0).pop() ?? 'resource';
46
+ const summaries: Record<string, string> = {
47
+ GET: `Get ${resource}`,
48
+ POST: `Create ${resource}`,
49
+ PUT: `Update ${resource}`,
50
+ PATCH: `Partially update ${resource}`,
51
+ DELETE: `Delete ${resource}`,
52
+ };
53
+ return summaries[method.toUpperCase()] ?? `${method} ${resource}`;
54
+ }
55
+
56
+ function inferTag(path: string): string {
57
+ const parts = path.split('/').filter(s => s.length > 0 && !s.startsWith('{'));
58
+ return parts[0] ?? 'default';
59
+ }
60
+
61
+ export function generateOpenApiSpec(
62
+ observations: ApiEndpointObservationInput[],
63
+ opts: { title?: string; baseUrl?: string; version?: string } = {},
64
+ ): OpenApiSpec {
65
+ const { title = 'ZeTa Discovered API', baseUrl = '/', version = '1.0.0' } = opts;
66
+
67
+ const spec: OpenApiSpec = {
68
+ openapi: '3.0.3',
69
+ info: { title, version, description: 'Auto-generated by ZeTa crawler from observed network traffic.' },
70
+ servers: [{ url: baseUrl }],
71
+ paths: {},
72
+ };
73
+
74
+ for (const obs of observations) {
75
+ const path = obs.urlPattern;
76
+ const method = obs.method.toLowerCase();
77
+
78
+ if (!spec.paths[path]) spec.paths[path] = {};
79
+
80
+ const params = extractPathParams(path);
81
+ const statusCode = obs.statusCode ?? 200;
82
+ const responses: OpenApiOperation['responses'] = {
83
+ [String(statusCode)]: {
84
+ description: statusCode < 400 ? 'Successful response' : 'Error response',
85
+ content: obs.responseShape
86
+ ? { 'application/json': { schema: shapeToJsonSchema(obs.responseShape) } }
87
+ : undefined,
88
+ },
89
+ };
90
+
91
+ const op: OpenApiOperation = {
92
+ summary: methodSummary(obs.method, path),
93
+ parameters: params.length > 0 ? params : undefined,
94
+ responses,
95
+ tags: [inferTag(path)],
96
+ };
97
+
98
+ spec.paths[path][method] = op;
99
+ }
100
+
101
+ return spec;
102
+ }
103
+
104
+ function shapeToJsonSchema(shape: unknown): unknown {
105
+ if (Array.isArray(shape)) return { type: 'array', items: shape.length > 0 ? shapeToJsonSchema(shape[0]) : {} };
106
+ if (shape && typeof shape === 'object') {
107
+ const props: Record<string, unknown> = {};
108
+ for (const [k, v] of Object.entries(shape as object)) props[k] = shapeToJsonSchema(v);
109
+ return { type: 'object', properties: props };
110
+ }
111
+ return { type: String(shape) };
112
+ }
113
+
114
+ export function specToYaml(spec: OpenApiSpec): string {
115
+ // Minimal YAML serializer — avoids yaml dep
116
+ function toYaml(val: unknown, indent = 0): string {
117
+ const pad = ' '.repeat(indent);
118
+ if (val === null || val === undefined) return 'null';
119
+ if (typeof val === 'string') return val.includes('\n') || val.includes(':') ? `"${val.replace(/"/g, '\\"')}"` : val;
120
+ if (typeof val === 'number' || typeof val === 'boolean') return String(val);
121
+ if (Array.isArray(val)) return val.map(v => `\n${pad}- ${toYaml(v, indent + 1).trimStart()}`).join('') || '[]';
122
+ if (typeof val === 'object') {
123
+ const entries = Object.entries(val as object);
124
+ if (entries.length === 0) return '{}';
125
+ return entries.map(([k, v]) => {
126
+ const vStr = toYaml(v, indent + 1);
127
+ return typeof v === 'object' && v !== null && !Array.isArray(v)
128
+ ? `\n${pad}${k}:${vStr}`
129
+ : `\n${pad}${k}: ${vStr.trimStart()}`;
130
+ }).join('');
131
+ }
132
+ return String(val);
133
+ }
134
+ return `openapi: "${spec.openapi}"${toYaml(spec, 0)}`;
135
+ }
@@ -0,0 +1,123 @@
1
+ /**
2
+ * PDF extraction — discovers and extracts text from PDF URLs found during crawl.
3
+ *
4
+ * When the HTTP engine or browser engine discovers a link to a .pdf file,
5
+ * this module fetches the PDF, extracts its text content, and returns it
6
+ * as a screen-compatible markdown string for test generation.
7
+ *
8
+ * Uses pdf-parse (already installed in API package, accessible via dynamic import).
9
+ */
10
+
11
+ const PDF_EXT = /\.pdf(\?.*)?$/i;
12
+ const PDF_MIME = 'application/pdf';
13
+
14
+ export interface PdfScreen {
15
+ url: string;
16
+ title: string;
17
+ markdown: string;
18
+ pageCount: number;
19
+ info: Record<string, string>;
20
+ sizeBytes: number;
21
+ }
22
+
23
+ export function isPdfUrl(url: string, contentType?: string): boolean {
24
+ return PDF_EXT.test(url) || (contentType?.includes(PDF_MIME) ?? false);
25
+ }
26
+
27
+ export async function extractPdfFromUrl(url: string, opts: {
28
+ userAgent?: string;
29
+ timeoutMs?: number;
30
+ maxSizeBytes?: number;
31
+ } = {}): Promise<PdfScreen | null> {
32
+ const { userAgent = 'ZeTa-Crawler/1.0', timeoutMs = 30_000, maxSizeBytes = 50 * 1024 * 1024 } = opts;
33
+
34
+ let buffer: Buffer;
35
+ let sizeBytes: number;
36
+
37
+ try {
38
+ const response = await fetch(url, {
39
+ headers: { 'User-Agent': userAgent, 'Accept': 'application/pdf,*/*' },
40
+ signal: AbortSignal.timeout(timeoutMs),
41
+ });
42
+ if (!response.ok) return null;
43
+
44
+ const contentType = response.headers.get('content-type') ?? '';
45
+ if (!contentType.includes('pdf') && !PDF_EXT.test(url)) return null;
46
+
47
+ const arrayBuffer = await response.arrayBuffer();
48
+ sizeBytes = arrayBuffer.byteLength;
49
+ if (sizeBytes > maxSizeBytes) {
50
+ console.warn(`[pdf-extractor] PDF too large (${sizeBytes} bytes), skipping: ${url}`);
51
+ return null;
52
+ }
53
+ buffer = Buffer.from(arrayBuffer);
54
+ } catch (e: any) {
55
+ console.error(`[pdf-extractor] Failed to fetch PDF ${url}: ${e.message}`);
56
+ return null;
57
+ }
58
+
59
+ try {
60
+ // pdf-parse is installed in the API package — use dynamic import
61
+ // so this file compiles without adding pdf-parse to crawler's package.json
62
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
63
+ // @ts-ignore – no types for pdf-parse/lib/pdf-parse.js
64
+ const pdfParse = await import('pdf-parse/lib/pdf-parse.js').catch(
65
+ () => import('pdf-parse' as any)
66
+ ) as any;
67
+ const parse = pdfParse.default ?? pdfParse;
68
+ const data = await parse(buffer, { max: 0 }); // max: 0 = all pages
69
+
70
+ const title = (data.info?.Title as string) || extractTitleFromUrl(url);
71
+ const markdown = pdfTextToMarkdown(data.text ?? '', title);
72
+
73
+ return {
74
+ url,
75
+ title,
76
+ markdown,
77
+ pageCount: data.numpages ?? 0,
78
+ info: {
79
+ author: data.info?.Author ?? '',
80
+ subject: data.info?.Subject ?? '',
81
+ keywords: data.info?.Keywords ?? '',
82
+ creator: data.info?.Creator ?? '',
83
+ },
84
+ sizeBytes,
85
+ };
86
+ } catch (e: any) {
87
+ console.error(`[pdf-extractor] Failed to parse PDF ${url}: ${e.message}`);
88
+ return null;
89
+ }
90
+ }
91
+
92
+ function extractTitleFromUrl(url: string): string {
93
+ try {
94
+ const { pathname } = new URL(url);
95
+ const filename = pathname.split('/').pop() ?? 'document';
96
+ return filename.replace(/\.pdf$/i, '').replace(/[-_]/g, ' ').trim();
97
+ } catch {
98
+ return 'PDF Document';
99
+ }
100
+ }
101
+
102
+ function pdfTextToMarkdown(text: string, title: string): string {
103
+ if (!text.trim()) return `# ${title}\n\n*No extractable text content.*`;
104
+
105
+ // Clean up common PDF extraction artifacts
106
+ const cleaned = text
107
+ .replace(/\f/g, '\n\n---\n\n') // form feed = page break
108
+ .replace(/\r\n/g, '\n')
109
+ .replace(/\r/g, '\n')
110
+ .replace(/\n{3,}/g, '\n\n')
111
+ .replace(/[ \t]{2,}/g, ' ')
112
+ .trim();
113
+
114
+ return `# ${title}\n\n${cleaned}`;
115
+ }
116
+
117
+ /**
118
+ * Filter a list of discovered URLs to find PDF links.
119
+ * Used by http-engine and browser crawler to decide which links to pass to extractPdfFromUrl.
120
+ */
121
+ export function filterPdfUrls(urls: string[]): string[] {
122
+ return urls.filter(u => PDF_EXT.test(u));
123
+ }
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Performance metrics collection via CDP + Playwright page.evaluate().
3
+ *
4
+ * Collects Core Web Vitals and resource timing after page navigation.
5
+ * Stored alongside screen data for test generation context and regression detection.
6
+ *
7
+ * Metrics:
8
+ * - LCP (Largest Contentful Paint) — via PerformanceObserver in page
9
+ * - CLS (Cumulative Layout Shift) — via PerformanceObserver in page
10
+ * - TTFB (Time to First Byte) — from navigation timing
11
+ * - DOMContentLoaded — from navigation timing
12
+ * - Resource count and size by type
13
+ */
14
+
15
+ import type { Page } from 'playwright';
16
+
17
+ export interface CoreWebVitals {
18
+ lcp?: number; // ms
19
+ cls?: number; // unitless score
20
+ ttfb?: number; // ms
21
+ fcp?: number; // First Contentful Paint ms
22
+ domContentLoaded?: number; // ms
23
+ loadEventEnd?: number; // ms
24
+ }
25
+
26
+ export interface ResourceSummary {
27
+ scripts: { count: number; totalBytes: number };
28
+ styles: { count: number; totalBytes: number };
29
+ images: { count: number; totalBytes: number };
30
+ fonts: { count: number; totalBytes: number };
31
+ other: { count: number; totalBytes: number };
32
+ }
33
+
34
+ export interface PagePerfMetrics {
35
+ url: string;
36
+ vitals: CoreWebVitals;
37
+ resources: ResourceSummary;
38
+ totalTransferBytes: number;
39
+ requestCount: number;
40
+ measuredAt: string;
41
+ }
42
+
43
+ export async function collectPerfMetrics(page: Page, url: string): Promise<PagePerfMetrics> {
44
+ const measuredAt = new Date().toISOString();
45
+
46
+ // Inject PerformanceObserver for LCP + CLS, wait up to 3s
47
+ let vitals: CoreWebVitals = {};
48
+ try {
49
+ vitals = await page.evaluate(() => {
50
+ return new Promise<any>((resolve) => {
51
+ const result: any = {};
52
+
53
+ // Navigation timing
54
+ const nav = performance.getEntriesByType('navigation')[0] as any;
55
+ if (nav) {
56
+ result.ttfb = Math.round(nav.responseStart - nav.requestStart);
57
+ result.domContentLoaded = Math.round(nav.domContentLoadedEventEnd);
58
+ result.loadEventEnd = Math.round(nav.loadEventEnd);
59
+ }
60
+
61
+ let lcpDone = false;
62
+ let clsDone = false;
63
+ const tryResolve = () => { if (lcpDone && clsDone) resolve(result); };
64
+
65
+ // LCP
66
+ try {
67
+ new PerformanceObserver((list) => {
68
+ const entries = list.getEntries();
69
+ if (entries.length > 0) result.lcp = Math.round((entries[entries.length - 1] as any).startTime);
70
+ lcpDone = true;
71
+ tryResolve();
72
+ }).observe({ type: 'largest-contentful-paint', buffered: true });
73
+ } catch { lcpDone = true; }
74
+
75
+ // CLS
76
+ try {
77
+ let cls = 0;
78
+ new PerformanceObserver((list) => {
79
+ for (const e of list.getEntries() as any[]) {
80
+ if (!e.hadRecentInput) cls += e.value;
81
+ }
82
+ result.cls = Math.round(cls * 1000) / 1000;
83
+ clsDone = true;
84
+ tryResolve();
85
+ }).observe({ type: 'layout-shift', buffered: true });
86
+ } catch { clsDone = true; }
87
+
88
+ // FCP
89
+ try {
90
+ const fcp = performance.getEntriesByName('first-contentful-paint')[0] as any;
91
+ if (fcp) result.fcp = Math.round(fcp.startTime);
92
+ } catch {}
93
+
94
+ // Fallback resolve after 3s
95
+ setTimeout(() => resolve(result), 3_000);
96
+ });
97
+ }).catch(() => ({}));
98
+ } catch { /* page closed or navigate already */ }
99
+
100
+ // Resource timing
101
+ let resources: ResourceSummary = {
102
+ scripts: { count: 0, totalBytes: 0 },
103
+ styles: { count: 0, totalBytes: 0 },
104
+ images: { count: 0, totalBytes: 0 },
105
+ fonts: { count: 0, totalBytes: 0 },
106
+ other: { count: 0, totalBytes: 0 },
107
+ };
108
+ let totalTransferBytes = 0;
109
+ let requestCount = 0;
110
+
111
+ try {
112
+ const entries = await page.evaluate(() =>
113
+ performance.getEntriesByType('resource').map((e: any) => ({
114
+ name: e.name,
115
+ initiatorType: e.initiatorType,
116
+ transferSize: e.transferSize ?? 0,
117
+ }))
118
+ ).catch(() => [] as any[]);
119
+
120
+ for (const e of entries) {
121
+ requestCount++;
122
+ totalTransferBytes += e.transferSize;
123
+ const cat = e.initiatorType === 'script' ? 'scripts'
124
+ : e.initiatorType === 'css' || e.name?.endsWith('.css') ? 'styles'
125
+ : e.initiatorType === 'img' ? 'images'
126
+ : e.name?.match(/\.(woff|woff2|ttf|otf|eot)/) ? 'fonts'
127
+ : 'other';
128
+ resources[cat as keyof ResourceSummary].count++;
129
+ resources[cat as keyof ResourceSummary].totalBytes += e.transferSize;
130
+ }
131
+ } catch {}
132
+
133
+ return { url, vitals, resources, totalTransferBytes, requestCount, measuredAt };
134
+ }