@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.
- package/Dockerfile +49 -0
- package/README.md +11 -0
- package/dist/accessibility-auditor.d.ts +29 -0
- package/dist/accessibility-auditor.js +57 -0
- package/dist/accessibility-auditor.js.map +7 -0
- package/dist/ai-login.d.ts +32 -0
- package/dist/ai-login.js +177 -0
- package/dist/ai-login.js.map +7 -0
- package/dist/auth-token-rotator.d.ts +47 -0
- package/dist/auth-token-rotator.js +58 -0
- package/dist/auth-token-rotator.js.map +7 -0
- package/dist/auth.d.ts +20 -0
- package/dist/auth.js +329 -0
- package/dist/auth.js.map +7 -0
- package/dist/browser-extension.d.ts +37 -0
- package/dist/browser-extension.js +113 -0
- package/dist/browser-extension.js.map +7 -0
- package/dist/captcha-solver.d.ts +16 -0
- package/dist/captcha-solver.js +105 -0
- package/dist/captcha-solver.js.map +7 -0
- package/dist/color-contrast.d.ts +30 -0
- package/dist/color-contrast.js +78 -0
- package/dist/color-contrast.js.map +7 -0
- package/dist/content-chunker.d.ts +18 -0
- package/dist/content-chunker.js +152 -0
- package/dist/content-chunker.js.map +7 -0
- package/dist/content-dedup.d.ts +23 -0
- package/dist/content-dedup.js +60 -0
- package/dist/content-dedup.js.map +7 -0
- package/dist/cookie-consent.d.ts +25 -0
- package/dist/cookie-consent.js +78 -0
- package/dist/cookie-consent.js.map +7 -0
- package/dist/cookie-jar.d.ts +20 -0
- package/dist/cookie-jar.js +61 -0
- package/dist/cookie-jar.js.map +7 -0
- package/dist/crawler.d.ts +179 -0
- package/dist/crawler.js +3672 -0
- package/dist/crawler.js.map +7 -0
- package/dist/custom-js-injector.d.ts +42 -0
- package/dist/custom-js-injector.js +50 -0
- package/dist/custom-js-injector.js.map +7 -0
- package/dist/dark-mode-capture.d.ts +23 -0
- package/dist/dark-mode-capture.js +75 -0
- package/dist/dark-mode-capture.js.map +7 -0
- package/dist/dead-link-detector.d.ts +37 -0
- package/dist/dead-link-detector.js +50 -0
- package/dist/dead-link-detector.js.map +7 -0
- package/dist/element-hash.d.ts +9 -0
- package/dist/element-hash.js +9 -0
- package/dist/element-hash.js.map +7 -0
- package/dist/email-otp.d.ts +42 -0
- package/dist/email-otp.js +177 -0
- package/dist/email-otp.js.map +7 -0
- package/dist/engine-router.d.ts +47 -0
- package/dist/engine-router.js +94 -0
- package/dist/engine-router.js.map +7 -0
- package/dist/export/bulk-export.d.ts +26 -0
- package/dist/export/bulk-export.js +76 -0
- package/dist/export/bulk-export.js.map +7 -0
- package/dist/export/vector-export.d.ts +76 -0
- package/dist/export/vector-export.js +85 -0
- package/dist/export/vector-export.js.map +7 -0
- package/dist/extractors/advanced.d.ts +54 -0
- package/dist/extractors/advanced.js +245 -0
- package/dist/extractors/advanced.js.map +7 -0
- package/dist/extractors/artifact-envelope.d.ts +63 -0
- package/dist/extractors/artifact-envelope.js +63 -0
- package/dist/extractors/artifact-envelope.js.map +7 -0
- package/dist/failure-analysis-agent.d.ts +16 -0
- package/dist/failure-analysis-agent.js +40 -0
- package/dist/failure-analysis-agent.js.map +7 -0
- package/dist/feature-flag-detector.d.ts +30 -0
- package/dist/feature-flag-detector.js +55 -0
- package/dist/feature-flag-detector.js.map +7 -0
- package/dist/form-detector.d.ts +34 -0
- package/dist/form-detector.js +58 -0
- package/dist/form-detector.js.map +7 -0
- package/dist/frontier.d.ts +32 -0
- package/dist/frontier.js +83 -0
- package/dist/frontier.js.map +7 -0
- package/dist/gap-pass.d.ts +35 -0
- package/dist/gap-pass.js +81 -0
- package/dist/gap-pass.js.map +7 -0
- package/dist/graphql-detector.d.ts +39 -0
- package/dist/graphql-detector.js +86 -0
- package/dist/graphql-detector.js.map +7 -0
- package/dist/http-engine.d.ts +80 -0
- package/dist/http-engine.js +304 -0
- package/dist/http-engine.js.map +7 -0
- package/dist/incremental-diff.d.ts +59 -0
- package/dist/incremental-diff.js +61 -0
- package/dist/incremental-diff.js.map +7 -0
- package/dist/index.d.ts +35 -0
- package/dist/index.js +61 -0
- package/dist/index.js.map +7 -0
- package/dist/input-fuzzer.d.ts +30 -0
- package/dist/input-fuzzer.js +105 -0
- package/dist/input-fuzzer.js.map +7 -0
- package/dist/interactive-probe.d.ts +104 -0
- package/dist/interactive-probe.js +442 -0
- package/dist/interactive-probe.js.map +7 -0
- package/dist/job-queue.d.ts +171 -0
- package/dist/job-queue.js +2256 -0
- package/dist/job-queue.js.map +7 -0
- package/dist/js-coverage.d.ts +29 -0
- package/dist/js-coverage.js +56 -0
- package/dist/js-coverage.js.map +7 -0
- package/dist/js-router-detector.d.ts +17 -0
- package/dist/js-router-detector.js +90 -0
- package/dist/js-router-detector.js.map +7 -0
- package/dist/language-detector.d.ts +22 -0
- package/dist/language-detector.js +57 -0
- package/dist/language-detector.js.map +7 -0
- package/dist/link-classifier.d.ts +23 -0
- package/dist/link-classifier.js +40 -0
- package/dist/link-classifier.js.map +7 -0
- package/dist/live-view.d.ts +36 -0
- package/dist/live-view.js +100 -0
- package/dist/live-view.js.map +7 -0
- package/dist/logger.d.ts +2 -0
- package/dist/logger.js +10 -0
- package/dist/logger.js.map +7 -0
- package/dist/markdown-extract.d.ts +8 -0
- package/dist/markdown-extract.js +54 -0
- package/dist/markdown-extract.js.map +7 -0
- package/dist/native-crawler.d.ts +69 -0
- package/dist/native-crawler.js +839 -0
- package/dist/native-crawler.js.map +7 -0
- package/dist/network-observer.d.ts +49 -0
- package/dist/network-observer.js +149 -0
- package/dist/network-observer.js.map +7 -0
- package/dist/network-throttle.d.ts +25 -0
- package/dist/network-throttle.js +34 -0
- package/dist/network-throttle.js.map +7 -0
- package/dist/openapi-generator.d.ts +49 -0
- package/dist/openapi-generator.js +89 -0
- package/dist/openapi-generator.js.map +7 -0
- package/dist/pdf-extractor.d.ts +28 -0
- package/dist/pdf-extractor.js +81 -0
- package/dist/pdf-extractor.js.map +7 -0
- package/dist/performance-metrics.d.ts +53 -0
- package/dist/performance-metrics.js +83 -0
- package/dist/performance-metrics.js.map +7 -0
- package/dist/phash.d.ts +38 -0
- package/dist/phash.js +115 -0
- package/dist/phash.js.map +7 -0
- package/dist/pii-scrubber.d.ts +21 -0
- package/dist/pii-scrubber.js +75 -0
- package/dist/pii-scrubber.js.map +7 -0
- package/dist/playwright-recorder.d.ts +39 -0
- package/dist/playwright-recorder.js +123 -0
- package/dist/playwright-recorder.js.map +7 -0
- package/dist/policies/crawl-safety.d.ts +9 -0
- package/dist/policies/crawl-safety.js +37 -0
- package/dist/policies/crawl-safety.js.map +7 -0
- package/dist/profiles/domain-profile.d.ts +19 -0
- package/dist/profiles/domain-profile.js +140 -0
- package/dist/profiles/domain-profile.js.map +7 -0
- package/dist/proxy.d.ts +50 -0
- package/dist/proxy.js +165 -0
- package/dist/proxy.js.map +7 -0
- package/dist/rate-limiter.d.ts +31 -0
- package/dist/rate-limiter.js +64 -0
- package/dist/rate-limiter.js.map +7 -0
- package/dist/redirect-tracker.d.ts +37 -0
- package/dist/redirect-tracker.js +65 -0
- package/dist/redirect-tracker.js.map +7 -0
- package/dist/redis-frontier.d.ts +48 -0
- package/dist/redis-frontier.js +126 -0
- package/dist/redis-frontier.js.map +7 -0
- package/dist/redis-queue.d.ts +48 -0
- package/dist/redis-queue.js +165 -0
- package/dist/redis-queue.js.map +7 -0
- package/dist/response-cache.d.ts +38 -0
- package/dist/response-cache.js +140 -0
- package/dist/response-cache.js.map +7 -0
- package/dist/responsive-detector.d.ts +29 -0
- package/dist/responsive-detector.js +46 -0
- package/dist/responsive-detector.js.map +7 -0
- package/dist/retry-policy.d.ts +20 -0
- package/dist/retry-policy.js +54 -0
- package/dist/retry-policy.js.map +7 -0
- package/dist/robots-txt.d.ts +9 -0
- package/dist/robots-txt.js +77 -0
- package/dist/robots-txt.js.map +7 -0
- package/dist/screen-reader.d.ts +16 -0
- package/dist/screen-reader.js +46 -0
- package/dist/screen-reader.js.map +7 -0
- package/dist/screenshot-masker.d.ts +28 -0
- package/dist/screenshot-masker.js +62 -0
- package/dist/screenshot-masker.js.map +7 -0
- package/dist/seo-checker.d.ts +33 -0
- package/dist/seo-checker.js +91 -0
- package/dist/seo-checker.js.map +7 -0
- package/dist/session-capture.d.ts +35 -0
- package/dist/session-capture.js +267 -0
- package/dist/session-capture.js.map +7 -0
- package/dist/session-export.d.ts +49 -0
- package/dist/session-export.js +117 -0
- package/dist/session-export.js.map +7 -0
- package/dist/sitemap-writer.d.ts +21 -0
- package/dist/sitemap-writer.js +47 -0
- package/dist/sitemap-writer.js.map +7 -0
- package/dist/sitemap.d.ts +51 -0
- package/dist/sitemap.js +152 -0
- package/dist/sitemap.js.map +7 -0
- package/dist/skyvern.d.ts +56 -0
- package/dist/skyvern.js +129 -0
- package/dist/skyvern.js.map +7 -0
- package/dist/sqs-queue.d.ts +2 -0
- package/dist/sqs-queue.js +47 -0
- package/dist/sqs-queue.js.map +7 -0
- package/dist/sso-token.d.ts +29 -0
- package/dist/sso-token.js +76 -0
- package/dist/sso-token.js.map +7 -0
- package/dist/stealth/human-behavior.d.ts +20 -0
- package/dist/stealth/human-behavior.js +168 -0
- package/dist/stealth/human-behavior.js.map +7 -0
- package/dist/telemetry.d.ts +34 -0
- package/dist/telemetry.js +77 -0
- package/dist/telemetry.js.map +7 -0
- package/dist/test-executor.d.ts +67 -0
- package/dist/test-executor.js +311 -0
- package/dist/test-executor.js.map +7 -0
- package/dist/tls-fingerprint.d.ts +50 -0
- package/dist/tls-fingerprint.js +81 -0
- package/dist/tls-fingerprint.js.map +7 -0
- package/dist/url-pattern-completer.d.ts +28 -0
- package/dist/url-pattern-completer.js +87 -0
- package/dist/url-pattern-completer.js.map +7 -0
- package/dist/visual-diff-verdict.d.ts +40 -0
- package/dist/visual-diff-verdict.js +25 -0
- package/dist/visual-diff-verdict.js.map +7 -0
- package/dist/webhook-notifier.d.ts +30 -0
- package/dist/webhook-notifier.js +76 -0
- package/dist/webhook-notifier.js.map +7 -0
- package/dist/websocket-detector.d.ts +24 -0
- package/dist/websocket-detector.js +60 -0
- package/dist/websocket-detector.js.map +7 -0
- package/dist/worker.d.ts +1 -0
- package/dist/worker.js +33 -0
- package/dist/worker.js.map +7 -0
- package/package.json +45 -0
- package/src/accessibility-auditor.ts +102 -0
- package/src/ai-login.ts +218 -0
- package/src/auth-token-rotator.ts +89 -0
- package/src/auth.ts +396 -0
- package/src/browser-extension.ts +186 -0
- package/src/captcha-solver.ts +143 -0
- package/src/color-contrast.ts +116 -0
- package/src/content-chunker.ts +170 -0
- package/src/content-dedup.ts +99 -0
- package/src/cookie-consent.ts +106 -0
- package/src/cookie-jar.ts +91 -0
- package/src/crawler.ts +4305 -0
- package/src/custom-js-injector.ts +93 -0
- package/src/dark-mode-capture.ts +110 -0
- package/src/dead-link-detector.ts +76 -0
- package/src/element-hash.ts +16 -0
- package/src/email-otp.ts +252 -0
- package/src/engine-router.ts +141 -0
- package/src/export/bulk-export.ts +85 -0
- package/src/export/vector-export.ts +160 -0
- package/src/extractors/advanced.ts +283 -0
- package/src/extractors/artifact-envelope.ts +111 -0
- package/src/failure-analysis-agent.ts +49 -0
- package/src/feature-flag-detector.ts +97 -0
- package/src/form-detector.ts +103 -0
- package/src/frontier.test.ts +64 -0
- package/src/frontier.ts +112 -0
- package/src/gap-pass.ts +122 -0
- package/src/graphql-detector.ts +129 -0
- package/src/http-engine.ts +433 -0
- package/src/incremental-diff.ts +121 -0
- package/src/index.ts +35 -0
- package/src/input-fuzzer.ts +129 -0
- package/src/interactive-probe.ts +623 -0
- package/src/job-queue.test.ts +120 -0
- package/src/job-queue.ts +2701 -0
- package/src/js-coverage.ts +87 -0
- package/src/js-router-detector.ts +112 -0
- package/src/language-detector.ts +82 -0
- package/src/link-classifier.ts +65 -0
- package/src/live-view.ts +141 -0
- package/src/logger.ts +9 -0
- package/src/markdown-extract.test.ts +73 -0
- package/src/markdown-extract.ts +76 -0
- package/src/native-crawler.ts +1106 -0
- package/src/network-observer.ts +204 -0
- package/src/network-throttle.ts +69 -0
- package/src/openapi-generator.ts +135 -0
- package/src/pdf-extractor.ts +123 -0
- package/src/performance-metrics.ts +134 -0
- package/src/phash.ts +151 -0
- package/src/pii-scrubber.ts +99 -0
- package/src/playwright-recorder.ts +170 -0
- package/src/policies/crawl-safety.ts +46 -0
- package/src/profiles/domain-profile.ts +167 -0
- package/src/proxy.ts +238 -0
- package/src/rate-limiter.ts +87 -0
- package/src/redirect-tracker.ts +97 -0
- package/src/redis-frontier.ts +143 -0
- package/src/redis-queue.ts +208 -0
- package/src/response-cache.ts +150 -0
- package/src/responsive-detector.ts +92 -0
- package/src/retry-policy.ts +87 -0
- package/src/robots-txt.ts +96 -0
- package/src/screen-reader.ts +89 -0
- package/src/screenshot-masker.ts +98 -0
- package/src/seo-checker.ts +134 -0
- package/src/session-capture.ts +325 -0
- package/src/session-export.test.ts +75 -0
- package/src/session-export.ts +172 -0
- package/src/sitemap-writer.ts +65 -0
- package/src/sitemap.test.ts +131 -0
- package/src/sitemap.ts +224 -0
- package/src/skyvern.ts +199 -0
- package/src/sqs-queue.ts +49 -0
- package/src/sso-token.ts +120 -0
- package/src/stealth/human-behavior.ts +191 -0
- package/src/telemetry.ts +122 -0
- package/src/test-executor.test.ts +72 -0
- package/src/test-executor.ts +424 -0
- package/src/tls-fingerprint.ts +111 -0
- package/src/url-pattern-completer.ts +114 -0
- package/src/visual-diff-verdict.test.ts +80 -0
- package/src/visual-diff-verdict.ts +57 -0
- package/src/webhook-notifier.ts +109 -0
- package/src/websocket-detector.ts +83 -0
- package/src/worker.ts +48 -0
- package/tsconfig.json +13 -0
- package/types/accessibility-auditor.d.ts +29 -0
- package/types/ai-login.d.ts +32 -0
- package/types/auth-token-rotator.d.ts +47 -0
- package/types/auth.d.ts +20 -0
- package/types/browser-extension.d.ts +37 -0
- package/types/bull-worker.d.ts +6 -0
- package/types/captcha-solver.d.ts +16 -0
- package/types/color-contrast.d.ts +30 -0
- package/types/content-chunker.d.ts +18 -0
- package/types/content-dedup.d.ts +23 -0
- package/types/cookie-consent.d.ts +25 -0
- package/types/cookie-jar.d.ts +20 -0
- package/types/crawler.d.ts +179 -0
- package/types/custom-js-injector.d.ts +42 -0
- package/types/dark-mode-capture.d.ts +23 -0
- package/types/dead-link-detector.d.ts +37 -0
- package/types/element-hash.d.ts +9 -0
- package/types/email-otp.d.ts +42 -0
- package/types/engine-router.d.ts +47 -0
- package/types/export/bulk-export.d.ts +26 -0
- package/types/export/vector-export.d.ts +76 -0
- package/types/extractors/advanced.d.ts +54 -0
- package/types/extractors/artifact-envelope.d.ts +63 -0
- package/types/failure-analysis-agent.d.ts +16 -0
- package/types/feature-flag-detector.d.ts +30 -0
- package/types/form-detector.d.ts +34 -0
- package/types/frontier.d.ts +32 -0
- package/types/gap-pass.d.ts +35 -0
- package/types/graphql-detector.d.ts +39 -0
- package/types/http-engine.d.ts +80 -0
- package/types/incremental-diff.d.ts +59 -0
- package/types/index.d.ts +35 -0
- package/types/input-fuzzer.d.ts +30 -0
- package/types/interactive-probe.d.ts +104 -0
- package/types/job-queue.d.ts +171 -0
- package/types/js-coverage.d.ts +29 -0
- package/types/js-router-detector.d.ts +17 -0
- package/types/language-detector.d.ts +22 -0
- package/types/link-classifier.d.ts +23 -0
- package/types/live-view.d.ts +36 -0
- package/types/logger.d.ts +2 -0
- package/types/markdown-extract.d.ts +8 -0
- package/types/native-crawler.d.ts +69 -0
- package/types/network-observer.d.ts +49 -0
- package/types/network-throttle.d.ts +25 -0
- package/types/openapi-generator.d.ts +49 -0
- package/types/pdf-extractor.d.ts +28 -0
- package/types/performance-metrics.d.ts +53 -0
- package/types/phash.d.ts +38 -0
- package/types/pii-scrubber.d.ts +21 -0
- package/types/playwright-recorder.d.ts +39 -0
- package/types/policies/crawl-safety.d.ts +9 -0
- package/types/profiles/domain-profile.d.ts +19 -0
- package/types/proxy.d.ts +50 -0
- package/types/rate-limiter.d.ts +31 -0
- package/types/redirect-tracker.d.ts +37 -0
- package/types/redis-frontier.d.ts +48 -0
- package/types/redis-queue.d.ts +48 -0
- package/types/response-cache.d.ts +38 -0
- package/types/responsive-detector.d.ts +29 -0
- package/types/retry-policy.d.ts +20 -0
- package/types/robots-txt.d.ts +9 -0
- package/types/screen-reader.d.ts +16 -0
- package/types/screenshot-masker.d.ts +28 -0
- package/types/seo-checker.d.ts +33 -0
- package/types/session-capture.d.ts +38 -0
- package/types/session-export.d.ts +49 -0
- package/types/sitemap-writer.d.ts +21 -0
- package/types/sitemap.d.ts +51 -0
- package/types/skyvern.d.ts +56 -0
- package/types/sqs-queue.d.ts +2 -0
- package/types/sso-token.d.ts +29 -0
- package/types/stealth/human-behavior.d.ts +20 -0
- package/types/telemetry.d.ts +34 -0
- package/types/test-executor.d.ts +67 -0
- package/types/tls-fingerprint.d.ts +50 -0
- package/types/url-pattern-completer.d.ts +28 -0
- package/types/visual-diff-verdict.d.ts +40 -0
- package/types/webhook-notifier.d.ts +30 -0
- package/types/websocket-detector.d.ts +24 -0
- package/types/worker.d.ts +1 -0
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../src/url-pattern-completer.ts"],
|
|
4
|
+
"sourcesContent": ["/**\n * P1: URL Pattern LLM Completion\n *\n * After BFS crawl completes, runs a single LLM call to predict URLs that exist\n * but were never linked from within the app. Validates each candidate with\n * HTTP HEAD before emitting as crawl evidence (URL_CANDIDATES).\n *\n * Cost: ~1 LLM call + N HEAD requests. Takes ~5-30 seconds depending on\n * candidate count. Does NOT launch a browser.\n */\n\nimport { logger as rootLogger } from './logger.js';\n\nconst logger = rootLogger.child({ module: 'url-pattern-completer' });\n\nexport interface UrlCandidate {\n url: string;\n confidence: number;\n reason: string;\n status: 'hit' | 'miss' | 'error';\n}\n\ninterface LlmCfg {\n provider: string;\n model: string;\n apiKey?: string;\n baseUrl?: string;\n}\n\n/**\n * Returns validated URL candidates (HEAD=200) predicted by LLM from\n * the pattern of already-discovered URLs.\n */\nexport async function runUrlPatternCompletion(\n discoveredUrls: string[],\n appUrl: string,\n llmCfg: LlmCfg | null | undefined,\n): Promise<UrlCandidate[]> {\n if (!llmCfg?.apiKey || discoveredUrls.length < 2) return [];\n\n const origin = (() => { try { return new URL(appUrl).origin; } catch { return ''; } })();\n if (!origin) return [];\n\n // Only send internal URLs (same origin, no assets)\n const ASSET_EXT = /\\.(png|jpg|jpeg|gif|svg|ico|webp|woff|woff2|ttf|eot|css|js|map|json|xml|txt|pdf)$/i;\n const internalUrls = discoveredUrls\n .filter(u => { try { return new URL(u).origin === origin && !ASSET_EXT.test(u); } catch { return false; } })\n .slice(0, 60); // cap context size\n\n if (internalUrls.length < 2) return [];\n\n const prompt = `You are analyzing a web application's URL structure to find screens that exist but weren't discovered during automated crawling.\n\nDiscovered URLs:\n${internalUrls.map(u => { try { return new URL(u).pathname + new URL(u).search; } catch { return u; } }).join('\\n')}\n\nBased on these URL patterns, suggest up to 15 additional URLs that likely exist in this application but weren't found \u2014 typically settings subpages, admin panels, profile variants, paginated views, or feature-gated routes.\n\nRules:\n- Only suggest URLs on the same domain\n- Use relative paths (starting with /)\n- Skip generic guesses (/login, /register, /home) unless the pattern strongly implies them\n- Focus on patterns YOU SEE in the discovered URLs (e.g. if /settings/profile exists, try /settings/security, /settings/billing)\n- Confidence 0.0\u20131.0: how certain you are the page exists\n\nRespond with ONLY valid JSON, no explanation:\n{\"candidates\":[{\"path\":\"/settings/billing\",\"confidence\":0.85,\"reason\":\"settings subpage pattern from /settings/profile\"},{\"path\":\"/admin/users\",\"confidence\":0.6,\"reason\":\"admin prefix implied by /admin/dashboard\"}]}`;\n\n try {\n const Anthropic = (await import('@anthropic-ai/sdk')).default;\n const client = new Anthropic({ apiKey: llmCfg.apiKey });\n const response = await client.messages.create({\n model: llmCfg.model ?? 'claude-haiku-4-5-20251001',\n max_tokens: 1024,\n // @ts-ignore: cache_control supported in beta\n system: [{ type: 'text', text: 'You are a URL pattern analyst. Always respond with valid JSON only.', cache_control: { type: 'ephemeral' } }],\n messages: [{ role: 'user', content: prompt }],\n });\n\n const text = (response.content[0] as any)?.text ?? '';\n const jsonMatch = text.match(/\\{[\\s\\S]*\\}/);\n if (!jsonMatch) return [];\n const parsed = JSON.parse(jsonMatch[0]);\n const rawCandidates: Array<{ path: string; confidence: number; reason: string }> =\n parsed.candidates ?? [];\n\n if (!Array.isArray(rawCandidates) || rawCandidates.length === 0) return [];\n\n // HEAD-validate each candidate\n const results: UrlCandidate[] = [];\n for (const c of rawCandidates.slice(0, 20)) {\n if (!c.path || typeof c.path !== 'string') continue;\n const fullUrl = c.path.startsWith('http') ? c.path : `${origin}${c.path.startsWith('/') ? '' : '/'}${c.path}`;\n try {\n const controller = new AbortController();\n const timeout = setTimeout(() => controller.abort(), 6_000);\n const res = await fetch(fullUrl, { method: 'HEAD', signal: controller.signal, redirect: 'follow' }).catch(() => null);\n clearTimeout(timeout);\n const status: UrlCandidate['status'] = res?.ok ? 'hit' : 'miss';\n results.push({ url: fullUrl, confidence: c.confidence ?? 0.5, reason: c.reason ?? '', status });\n logger.info({ url: fullUrl, httpStatus: res?.status ?? 'timeout', status }, '[url-completer] candidate checked');\n } catch {\n results.push({ url: fullUrl, confidence: c.confidence ?? 0.5, reason: c.reason ?? '', status: 'error' });\n }\n }\n\n const hits = results.filter(r => r.status === 'hit');\n logger.info({ total: results.length, hits: hits.length }, '[url-completer] pattern completion done');\n return results;\n } catch (err) {\n logger.warn({ err: String(err) }, '[url-completer] LLM call failed \u2014 skipping pattern completion');\n return [];\n }\n}\n"],
|
|
5
|
+
"mappings": "AAWA,SAAS,UAAU,kBAAkB;AAErC,MAAM,SAAS,WAAW,MAAM,EAAE,QAAQ,wBAAwB,CAAC;AAoBnE,eAAsB,wBACpB,gBACA,QACA,QACyB;AACzB,MAAI,CAAC,QAAQ,UAAU,eAAe,SAAS,EAAG,QAAO,CAAC;AAE1D,QAAM,UAAU,MAAM;AAAE,QAAI;AAAE,aAAO,IAAI,IAAI,MAAM,EAAE;AAAA,IAAQ,QAAQ;AAAE,aAAO;AAAA,IAAI;AAAA,EAAE,GAAG;AACvF,MAAI,CAAC,OAAQ,QAAO,CAAC;AAGrB,QAAM,YAAY;AAClB,QAAM,eAAe,eAClB,OAAO,OAAK;AAAE,QAAI;AAAE,aAAO,IAAI,IAAI,CAAC,EAAE,WAAW,UAAU,CAAC,UAAU,KAAK,CAAC;AAAA,IAAG,QAAQ;AAAE,aAAO;AAAA,IAAO;AAAA,EAAE,CAAC,EAC1G,MAAM,GAAG,EAAE;AAEd,MAAI,aAAa,SAAS,EAAG,QAAO,CAAC;AAErC,QAAM,SAAS;AAAA;AAAA;AAAA,EAGf,aAAa,IAAI,OAAK;AAAE,QAAI;AAAE,aAAO,IAAI,IAAI,CAAC,EAAE,WAAW,IAAI,IAAI,CAAC,EAAE;AAAA,IAAQ,QAAQ;AAAE,aAAO;AAAA,IAAG;AAAA,EAAE,CAAC,EAAE,KAAK,IAAI,CAAC;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAcjH,MAAI;AACF,UAAM,aAAa,MAAM,OAAO,mBAAmB,GAAG;AACtD,UAAM,SAAS,IAAI,UAAU,EAAE,QAAQ,OAAO,OAAO,CAAC;AACtD,UAAM,WAAW,MAAM,OAAO,SAAS,OAAO;AAAA,MAC5C,OAAO,OAAO,SAAS;AAAA,MACvB,YAAY;AAAA;AAAA,MAEZ,QAAQ,CAAC,EAAE,MAAM,QAAQ,MAAM,uEAAuE,eAAe,EAAE,MAAM,YAAY,EAAE,CAAC;AAAA,MAC5I,UAAU,CAAC,EAAE,MAAM,QAAQ,SAAS,OAAO,CAAC;AAAA,IAC9C,CAAC;AAED,UAAM,OAAQ,SAAS,QAAQ,CAAC,GAAW,QAAQ;AACnD,UAAM,YAAY,KAAK,MAAM,aAAa;AAC1C,QAAI,CAAC,UAAW,QAAO,CAAC;AACxB,UAAM,SAAS,KAAK,MAAM,UAAU,CAAC,CAAC;AACtC,UAAM,gBACJ,OAAO,cAAc,CAAC;AAExB,QAAI,CAAC,MAAM,QAAQ,aAAa,KAAK,cAAc,WAAW,EAAG,QAAO,CAAC;AAGzE,UAAM,UAA0B,CAAC;AACjC,eAAW,KAAK,cAAc,MAAM,GAAG,EAAE,GAAG;AAC1C,UAAI,CAAC,EAAE,QAAQ,OAAO,EAAE,SAAS,SAAU;AAC3C,YAAM,UAAU,EAAE,KAAK,WAAW,MAAM,IAAI,EAAE,OAAO,GAAG,MAAM,GAAG,EAAE,KAAK,WAAW,GAAG,IAAI,KAAK,GAAG,GAAG,EAAE,IAAI;AAC3G,UAAI;AACF,cAAM,aAAa,IAAI,gBAAgB;AACvC,cAAM,UAAU,WAAW,MAAM,WAAW,MAAM,GAAG,GAAK;AAC1D,cAAM,MAAM,MAAM,MAAM,SAAS,EAAE,QAAQ,QAAQ,QAAQ,WAAW,QAAQ,UAAU,SAAS,CAAC,EAAE,MAAM,MAAM,IAAI;AACpH,qBAAa,OAAO;AACpB,cAAM,SAAiC,KAAK,KAAK,QAAQ;AACzD,gBAAQ,KAAK,EAAE,KAAK,SAAS,YAAY,EAAE,cAAc,KAAK,QAAQ,EAAE,UAAU,IAAI,OAAO,CAAC;AAC9F,eAAO,KAAK,EAAE,KAAK,SAAS,YAAY,KAAK,UAAU,WAAW,OAAO,GAAG,mCAAmC;AAAA,MACjH,QAAQ;AACN,gBAAQ,KAAK,EAAE,KAAK,SAAS,YAAY,EAAE,cAAc,KAAK,QAAQ,EAAE,UAAU,IAAI,QAAQ,QAAQ,CAAC;AAAA,MACzG;AAAA,IACF;AAEA,UAAM,OAAO,QAAQ,OAAO,OAAK,EAAE,WAAW,KAAK;AACnD,WAAO,KAAK,EAAE,OAAO,QAAQ,QAAQ,MAAM,KAAK,OAAO,GAAG,yCAAyC;AACnG,WAAO;AAAA,EACT,SAAS,KAAK;AACZ,WAAO,KAAK,EAAE,KAAK,OAAO,GAAG,EAAE,GAAG,oEAA+D;AACjG,WAAO,CAAC;AAAA,EACV;AACF;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure visual-diff verdict logic, split out of crawler.ts so it can be unit-tested
|
|
3
|
+
* without pulling in crawler.ts's full import graph (Playwright, Stagehand, and
|
|
4
|
+
* transitively @qip/database, which instantiates a PrismaClient at module load and
|
|
5
|
+
* so requires a live DATABASE_URL just to import the module).
|
|
6
|
+
*/
|
|
7
|
+
export interface VisualDiffVerdict {
|
|
8
|
+
changeType: 'NONE' | 'MINOR' | 'SIGNIFICANT' | 'CRITICAL';
|
|
9
|
+
recommendation: 'APPROVE' | 'REVIEW' | 'FLAG' | 'BLOCK';
|
|
10
|
+
changes: Array<{
|
|
11
|
+
location: string;
|
|
12
|
+
severity: string;
|
|
13
|
+
description: string;
|
|
14
|
+
}>;
|
|
15
|
+
bugReportDraft?: {
|
|
16
|
+
title: string;
|
|
17
|
+
severity: string;
|
|
18
|
+
steps: string[];
|
|
19
|
+
expected: string;
|
|
20
|
+
actual: string;
|
|
21
|
+
};
|
|
22
|
+
aiSummary?: string;
|
|
23
|
+
}
|
|
24
|
+
/** Cheap, always-on first-pass verdict from perceptual hash distance + layout/element diffs. */
|
|
25
|
+
export declare function pHashVerdict(pHashDistance: number, changeLevel: 'IDENTICAL' | 'MINOR' | 'MODERATE' | 'SIGNIFICANT', changes: Array<{
|
|
26
|
+
location: string;
|
|
27
|
+
severity: string;
|
|
28
|
+
description: string;
|
|
29
|
+
}>): VisualDiffVerdict;
|
|
30
|
+
/** Replaces a pHash-only verdict with the AI's semantic judgment once it's available,
|
|
31
|
+
* and drafts a bug report for anything still CRITICAL/SIGNIFICANT after AI review
|
|
32
|
+
* (pHash flags plenty of anti-aliasing/font-rendering noise the AI correctly discards
|
|
33
|
+
* as NONE/MINOR — only what survives AI review is worth a draft). */
|
|
34
|
+
export declare function mergeAiVisualVerdict(pHashResult: VisualDiffVerdict, aiResult: {
|
|
35
|
+
changeType: string;
|
|
36
|
+
changes: any[];
|
|
37
|
+
recommendation: string;
|
|
38
|
+
diffScore: number;
|
|
39
|
+
summary?: string;
|
|
40
|
+
}, screenName: string): VisualDiffVerdict;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
function pHashVerdict(pHashDistance, changeLevel, changes) {
|
|
2
|
+
const changeType = changeLevel === "IDENTICAL" ? "NONE" : changeLevel === "MINOR" ? "MINOR" : changeLevel === "MODERATE" ? "SIGNIFICANT" : "CRITICAL";
|
|
3
|
+
const recommendation = pHashDistance === 0 ? "APPROVE" : pHashDistance < 5 ? "APPROVE" : pHashDistance < 20 ? "REVIEW" : "FLAG";
|
|
4
|
+
return { changeType, recommendation, changes };
|
|
5
|
+
}
|
|
6
|
+
function mergeAiVisualVerdict(pHashResult, aiResult, screenName) {
|
|
7
|
+
if (!aiResult.changeType) return pHashResult;
|
|
8
|
+
const changeType = aiResult.changeType;
|
|
9
|
+
const recommendation = aiResult.recommendation;
|
|
10
|
+
const changes = Array.isArray(aiResult.changes) && aiResult.changes.length > 0 ? aiResult.changes : pHashResult.changes;
|
|
11
|
+
const aiSummary = aiResult.summary ?? void 0;
|
|
12
|
+
const bugReportDraft = changeType === "CRITICAL" || changeType === "SIGNIFICANT" ? {
|
|
13
|
+
title: `Visual regression on "${screenName}"`,
|
|
14
|
+
severity: changeType === "CRITICAL" ? "High" : "Medium",
|
|
15
|
+
steps: ["Navigate to the screen", "Compare current view to baseline screenshot"],
|
|
16
|
+
expected: "Screen appearance should match the stored baseline.",
|
|
17
|
+
actual: aiSummary || `Screen has changed (diff score: ${aiResult.diffScore.toFixed(1)}%)`
|
|
18
|
+
} : void 0;
|
|
19
|
+
return { changeType, recommendation, changes, bugReportDraft, aiSummary };
|
|
20
|
+
}
|
|
21
|
+
export {
|
|
22
|
+
mergeAiVisualVerdict,
|
|
23
|
+
pHashVerdict
|
|
24
|
+
};
|
|
25
|
+
//# sourceMappingURL=visual-diff-verdict.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../src/visual-diff-verdict.ts"],
|
|
4
|
+
"sourcesContent": ["/**\n * Pure visual-diff verdict logic, split out of crawler.ts so it can be unit-tested\n * without pulling in crawler.ts's full import graph (Playwright, Stagehand, and\n * transitively @qip/database, which instantiates a PrismaClient at module load and\n * so requires a live DATABASE_URL just to import the module).\n */\n\nexport interface VisualDiffVerdict {\n changeType: 'NONE' | 'MINOR' | 'SIGNIFICANT' | 'CRITICAL';\n recommendation: 'APPROVE' | 'REVIEW' | 'FLAG' | 'BLOCK';\n changes: Array<{ location: string; severity: string; description: string }>;\n bugReportDraft?: { title: string; severity: string; steps: string[]; expected: string; actual: string };\n aiSummary?: string;\n}\n\n/** Cheap, always-on first-pass verdict from perceptual hash distance + layout/element diffs. */\nexport function pHashVerdict(\n pHashDistance: number,\n changeLevel: 'IDENTICAL' | 'MINOR' | 'MODERATE' | 'SIGNIFICANT',\n changes: Array<{ location: string; severity: string; description: string }>,\n): VisualDiffVerdict {\n const changeType: VisualDiffVerdict['changeType'] = changeLevel === 'IDENTICAL' ? 'NONE'\n : changeLevel === 'MINOR' ? 'MINOR'\n : changeLevel === 'MODERATE' ? 'SIGNIFICANT'\n : 'CRITICAL';\n const recommendation: VisualDiffVerdict['recommendation'] = pHashDistance === 0 ? 'APPROVE'\n : pHashDistance < 5 ? 'APPROVE'\n : pHashDistance < 20 ? 'REVIEW'\n : 'FLAG';\n return { changeType, recommendation, changes };\n}\n\n/** Replaces a pHash-only verdict with the AI's semantic judgment once it's available,\n * and drafts a bug report for anything still CRITICAL/SIGNIFICANT after AI review\n * (pHash flags plenty of anti-aliasing/font-rendering noise the AI correctly discards\n * as NONE/MINOR \u2014 only what survives AI review is worth a draft). */\nexport function mergeAiVisualVerdict(\n pHashResult: VisualDiffVerdict,\n aiResult: { changeType: string; changes: any[]; recommendation: string; diffScore: number; summary?: string },\n screenName: string,\n): VisualDiffVerdict {\n if (!aiResult.changeType) return pHashResult;\n const changeType = aiResult.changeType as VisualDiffVerdict['changeType'];\n const recommendation = aiResult.recommendation as VisualDiffVerdict['recommendation'];\n const changes = Array.isArray(aiResult.changes) && aiResult.changes.length > 0 ? aiResult.changes : pHashResult.changes;\n const aiSummary = aiResult.summary ?? undefined;\n const bugReportDraft = (changeType === 'CRITICAL' || changeType === 'SIGNIFICANT')\n ? {\n title: `Visual regression on \"${screenName}\"`,\n severity: changeType === 'CRITICAL' ? 'High' : 'Medium',\n steps: ['Navigate to the screen', 'Compare current view to baseline screenshot'],\n expected: 'Screen appearance should match the stored baseline.',\n actual: aiSummary || `Screen has changed (diff score: ${aiResult.diffScore.toFixed(1)}%)`,\n }\n : undefined;\n return { changeType, recommendation, changes, bugReportDraft, aiSummary };\n}\n"],
|
|
5
|
+
"mappings": "AAgBO,SAAS,aACd,eACA,aACA,SACmB;AACnB,QAAM,aAA8C,gBAAgB,cAAc,SAC9E,gBAAgB,UAAU,UACxB,gBAAgB,aAAa,gBAC3B;AACR,QAAM,iBAAsD,kBAAkB,IAAI,YAC9E,gBAAgB,IAAI,YAClB,gBAAgB,KAAK,WACnB;AACR,SAAO,EAAE,YAAY,gBAAgB,QAAQ;AAC/C;AAMO,SAAS,qBACd,aACA,UACA,YACmB;AACnB,MAAI,CAAC,SAAS,WAAY,QAAO;AACjC,QAAM,aAAa,SAAS;AAC5B,QAAM,iBAAiB,SAAS;AAChC,QAAM,UAAU,MAAM,QAAQ,SAAS,OAAO,KAAK,SAAS,QAAQ,SAAS,IAAI,SAAS,UAAU,YAAY;AAChH,QAAM,YAAY,SAAS,WAAW;AACtC,QAAM,iBAAkB,eAAe,cAAc,eAAe,gBAChE;AAAA,IACE,OAAO,yBAAyB,UAAU;AAAA,IAC1C,UAAU,eAAe,aAAa,SAAS;AAAA,IAC/C,OAAO,CAAC,0BAA0B,6CAA6C;AAAA,IAC/E,UAAU;AAAA,IACV,QAAQ,aAAa,mCAAmC,SAAS,UAAU,QAAQ,CAAC,CAAC;AAAA,EACvF,IACA;AACJ,SAAO,EAAE,YAAY,gBAAgB,SAAS,gBAAgB,UAAU;AAC1E;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Webhook notifier — fire HTTP POST callbacks on crawl lifecycle events.
|
|
3
|
+
*
|
|
4
|
+
* Supports: crawl.started, crawl.progress, crawl.completed, crawl.failed
|
|
5
|
+
* Payload format compatible with Slack, Discord, and generic REST webhooks.
|
|
6
|
+
* Retries 3x on failure with exponential backoff.
|
|
7
|
+
*/
|
|
8
|
+
export type CrawlEventType = 'crawl.started' | 'crawl.progress' | 'crawl.completed' | 'crawl.failed';
|
|
9
|
+
export interface WebhookPayload {
|
|
10
|
+
event: CrawlEventType;
|
|
11
|
+
jobId: string;
|
|
12
|
+
projectId: string;
|
|
13
|
+
tenantId: string;
|
|
14
|
+
timestamp: string;
|
|
15
|
+
data: Record<string, unknown>;
|
|
16
|
+
}
|
|
17
|
+
export interface WebhookNotifierOpts {
|
|
18
|
+
url: string;
|
|
19
|
+
secret?: string;
|
|
20
|
+
headers?: Record<string, string>;
|
|
21
|
+
timeoutMs?: number;
|
|
22
|
+
maxRetries?: number;
|
|
23
|
+
}
|
|
24
|
+
export declare class WebhookNotifier {
|
|
25
|
+
private readonly opts;
|
|
26
|
+
constructor(opts: WebhookNotifierOpts);
|
|
27
|
+
notify(event: CrawlEventType, jobId: string, projectId: string, tenantId: string, data?: Record<string, unknown>): Promise<void>;
|
|
28
|
+
/** Slack-compatible message format */
|
|
29
|
+
static slackPayload(event: CrawlEventType, data: Record<string, unknown>): Record<string, unknown>;
|
|
30
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
async function hmacSign(secret, payload) {
|
|
2
|
+
const key = await crypto.subtle.importKey(
|
|
3
|
+
"raw",
|
|
4
|
+
new TextEncoder().encode(secret),
|
|
5
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
6
|
+
false,
|
|
7
|
+
["sign"]
|
|
8
|
+
);
|
|
9
|
+
const sig = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(payload));
|
|
10
|
+
return Array.from(new Uint8Array(sig)).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
11
|
+
}
|
|
12
|
+
class WebhookNotifier {
|
|
13
|
+
opts;
|
|
14
|
+
constructor(opts) {
|
|
15
|
+
this.opts = {
|
|
16
|
+
url: opts.url,
|
|
17
|
+
secret: opts.secret,
|
|
18
|
+
headers: opts.headers ?? {},
|
|
19
|
+
timeoutMs: opts.timeoutMs ?? 1e4,
|
|
20
|
+
maxRetries: opts.maxRetries ?? 3
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
async notify(event, jobId, projectId, tenantId, data = {}) {
|
|
24
|
+
const payload = {
|
|
25
|
+
event,
|
|
26
|
+
jobId,
|
|
27
|
+
projectId,
|
|
28
|
+
tenantId,
|
|
29
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
30
|
+
data
|
|
31
|
+
};
|
|
32
|
+
const body = JSON.stringify(payload);
|
|
33
|
+
const headers = {
|
|
34
|
+
"Content-Type": "application/json",
|
|
35
|
+
"User-Agent": "ZeTa-Crawler/1.0",
|
|
36
|
+
...this.opts.headers
|
|
37
|
+
};
|
|
38
|
+
if (this.opts.secret) {
|
|
39
|
+
headers["X-ZeTa-Signature"] = `sha256=${await hmacSign(this.opts.secret, body)}`;
|
|
40
|
+
}
|
|
41
|
+
let lastErr;
|
|
42
|
+
for (let attempt = 0; attempt <= this.opts.maxRetries; attempt++) {
|
|
43
|
+
try {
|
|
44
|
+
const res = await fetch(this.opts.url, {
|
|
45
|
+
method: "POST",
|
|
46
|
+
headers,
|
|
47
|
+
body,
|
|
48
|
+
signal: AbortSignal.timeout(this.opts.timeoutMs)
|
|
49
|
+
});
|
|
50
|
+
if (res.ok) return;
|
|
51
|
+
lastErr = new Error(`Webhook HTTP ${res.status}`);
|
|
52
|
+
} catch (e) {
|
|
53
|
+
lastErr = e;
|
|
54
|
+
}
|
|
55
|
+
if (attempt < this.opts.maxRetries) {
|
|
56
|
+
await new Promise((r) => setTimeout(r, 1e3 * Math.pow(2, attempt)));
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
console.error(`[webhook] Failed to deliver ${event} after ${this.opts.maxRetries + 1} attempts:`, lastErr?.message);
|
|
60
|
+
}
|
|
61
|
+
/** Slack-compatible message format */
|
|
62
|
+
static slackPayload(event, data) {
|
|
63
|
+
const emoji = event === "crawl.completed" ? "\u2705" : event === "crawl.failed" ? "\u274C" : "\u{1F504}";
|
|
64
|
+
return {
|
|
65
|
+
text: `${emoji} *ZeTa Crawl Event: ${event}*`,
|
|
66
|
+
blocks: [
|
|
67
|
+
{ type: "section", text: { type: "mrkdwn", text: `${emoji} *${event}*
|
|
68
|
+
${Object.entries(data).map(([k, v]) => `\u2022 *${k}*: ${v}`).join("\n")}` } }
|
|
69
|
+
]
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
export {
|
|
74
|
+
WebhookNotifier
|
|
75
|
+
};
|
|
76
|
+
//# sourceMappingURL=webhook-notifier.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../src/webhook-notifier.ts"],
|
|
4
|
+
"sourcesContent": ["/**\n * Webhook notifier \u2014 fire HTTP POST callbacks on crawl lifecycle events.\n *\n * Supports: crawl.started, crawl.progress, crawl.completed, crawl.failed\n * Payload format compatible with Slack, Discord, and generic REST webhooks.\n * Retries 3x on failure with exponential backoff.\n */\n\nexport type CrawlEventType = 'crawl.started' | 'crawl.progress' | 'crawl.completed' | 'crawl.failed';\n\nexport interface WebhookPayload {\n event: CrawlEventType;\n jobId: string;\n projectId: string;\n tenantId: string;\n timestamp: string;\n data: Record<string, unknown>;\n}\n\nexport interface WebhookNotifierOpts {\n url: string;\n secret?: string; // HMAC-SHA256 signing secret \u2014 adds X-ZeTa-Signature header\n headers?: Record<string, string>;\n timeoutMs?: number; // default 10s\n maxRetries?: number; // default 3\n}\n\nasync function hmacSign(secret: string, payload: string): Promise<string> {\n const key = await crypto.subtle.importKey(\n 'raw',\n new TextEncoder().encode(secret),\n { name: 'HMAC', hash: 'SHA-256' },\n false,\n ['sign'],\n );\n const sig = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(payload));\n return Array.from(new Uint8Array(sig)).map(b => b.toString(16).padStart(2, '0')).join('');\n}\n\nexport class WebhookNotifier {\n private readonly opts: Required<Omit<WebhookNotifierOpts, 'secret'>> & { secret?: string };\n\n constructor(opts: WebhookNotifierOpts) {\n this.opts = {\n url: opts.url,\n secret: opts.secret,\n headers: opts.headers ?? {},\n timeoutMs: opts.timeoutMs ?? 10_000,\n maxRetries: opts.maxRetries ?? 3,\n };\n }\n\n async notify(\n event: CrawlEventType,\n jobId: string,\n projectId: string,\n tenantId: string,\n data: Record<string, unknown> = {},\n ): Promise<void> {\n const payload: WebhookPayload = {\n event,\n jobId,\n projectId,\n tenantId,\n timestamp: new Date().toISOString(),\n data,\n };\n const body = JSON.stringify(payload);\n const headers: Record<string, string> = {\n 'Content-Type': 'application/json',\n 'User-Agent': 'ZeTa-Crawler/1.0',\n ...this.opts.headers,\n };\n if (this.opts.secret) {\n headers['X-ZeTa-Signature'] = `sha256=${await hmacSign(this.opts.secret, body)}`;\n }\n\n let lastErr: any;\n for (let attempt = 0; attempt <= this.opts.maxRetries; attempt++) {\n try {\n const res = await fetch(this.opts.url, {\n method: 'POST',\n headers,\n body,\n signal: AbortSignal.timeout(this.opts.timeoutMs),\n });\n if (res.ok) return;\n lastErr = new Error(`Webhook HTTP ${res.status}`);\n } catch (e) {\n lastErr = e;\n }\n if (attempt < this.opts.maxRetries) {\n await new Promise(r => setTimeout(r, 1_000 * Math.pow(2, attempt)));\n }\n }\n console.error(`[webhook] Failed to deliver ${event} after ${this.opts.maxRetries + 1} attempts:`, lastErr?.message);\n }\n\n /** Slack-compatible message format */\n static slackPayload(event: CrawlEventType, data: Record<string, unknown>): Record<string, unknown> {\n const emoji = event === 'crawl.completed' ? '\u2705' : event === 'crawl.failed' ? '\u274C' : '\uD83D\uDD04';\n return {\n text: `${emoji} *ZeTa Crawl Event: ${event}*`,\n blocks: [\n { type: 'section', text: { type: 'mrkdwn', text: `${emoji} *${event}*\\n${Object.entries(data).map(([k, v]) => `\u2022 *${k}*: ${v}`).join('\\n')}` } },\n ],\n };\n }\n}\n"],
|
|
5
|
+
"mappings": "AA2BA,eAAe,SAAS,QAAgB,SAAkC;AACxE,QAAM,MAAM,MAAM,OAAO,OAAO;AAAA,IAC9B;AAAA,IACA,IAAI,YAAY,EAAE,OAAO,MAAM;AAAA,IAC/B,EAAE,MAAM,QAAQ,MAAM,UAAU;AAAA,IAChC;AAAA,IACA,CAAC,MAAM;AAAA,EACT;AACA,QAAM,MAAM,MAAM,OAAO,OAAO,KAAK,QAAQ,KAAK,IAAI,YAAY,EAAE,OAAO,OAAO,CAAC;AACnF,SAAO,MAAM,KAAK,IAAI,WAAW,GAAG,CAAC,EAAE,IAAI,OAAK,EAAE,SAAS,EAAE,EAAE,SAAS,GAAG,GAAG,CAAC,EAAE,KAAK,EAAE;AAC1F;AAEO,MAAM,gBAAgB;AAAA,EACV;AAAA,EAEjB,YAAY,MAA2B;AACrC,SAAK,OAAO;AAAA,MACV,KAAK,KAAK;AAAA,MACV,QAAQ,KAAK;AAAA,MACb,SAAS,KAAK,WAAW,CAAC;AAAA,MAC1B,WAAW,KAAK,aAAa;AAAA,MAC7B,YAAY,KAAK,cAAc;AAAA,IACjC;AAAA,EACF;AAAA,EAEA,MAAM,OACJ,OACA,OACA,WACA,UACA,OAAgC,CAAC,GAClB;AACf,UAAM,UAA0B;AAAA,MAC9B;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA,YAAW,oBAAI,KAAK,GAAE,YAAY;AAAA,MAClC;AAAA,IACF;AACA,UAAM,OAAO,KAAK,UAAU,OAAO;AACnC,UAAM,UAAkC;AAAA,MACtC,gBAAgB;AAAA,MAChB,cAAc;AAAA,MACd,GAAG,KAAK,KAAK;AAAA,IACf;AACA,QAAI,KAAK,KAAK,QAAQ;AACpB,cAAQ,kBAAkB,IAAI,UAAU,MAAM,SAAS,KAAK,KAAK,QAAQ,IAAI,CAAC;AAAA,IAChF;AAEA,QAAI;AACJ,aAAS,UAAU,GAAG,WAAW,KAAK,KAAK,YAAY,WAAW;AAChE,UAAI;AACF,cAAM,MAAM,MAAM,MAAM,KAAK,KAAK,KAAK;AAAA,UACrC,QAAQ;AAAA,UACR;AAAA,UACA;AAAA,UACA,QAAQ,YAAY,QAAQ,KAAK,KAAK,SAAS;AAAA,QACjD,CAAC;AACD,YAAI,IAAI,GAAI;AACZ,kBAAU,IAAI,MAAM,gBAAgB,IAAI,MAAM,EAAE;AAAA,MAClD,SAAS,GAAG;AACV,kBAAU;AAAA,MACZ;AACA,UAAI,UAAU,KAAK,KAAK,YAAY;AAClC,cAAM,IAAI,QAAQ,OAAK,WAAW,GAAG,MAAQ,KAAK,IAAI,GAAG,OAAO,CAAC,CAAC;AAAA,MACpE;AAAA,IACF;AACA,YAAQ,MAAM,+BAA+B,KAAK,UAAU,KAAK,KAAK,aAAa,CAAC,cAAc,SAAS,OAAO;AAAA,EACpH;AAAA;AAAA,EAGA,OAAO,aAAa,OAAuB,MAAwD;AACjG,UAAM,QAAQ,UAAU,oBAAoB,WAAM,UAAU,iBAAiB,WAAM;AACnF,WAAO;AAAA,MACL,MAAM,GAAG,KAAK,uBAAuB,KAAK;AAAA,MAC1C,QAAQ;AAAA,QACN,EAAE,MAAM,WAAW,MAAM,EAAE,MAAM,UAAU,MAAM,GAAG,KAAK,KAAK,KAAK;AAAA,EAAM,OAAO,QAAQ,IAAI,EAAE,IAAI,CAAC,CAAC,GAAG,CAAC,MAAM,WAAM,CAAC,MAAM,CAAC,EAAE,EAAE,KAAK,IAAI,CAAC,GAAG,EAAE;AAAA,MACjJ;AAAA,IACF;AAAA,EACF;AACF;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebSocket detection — intercepts WS connections opened during page crawl.
|
|
3
|
+
* Records connection URLs, message schemas, and message counts.
|
|
4
|
+
* This lets ZeTa discover real-time API contracts (chat, notifications, presence)
|
|
5
|
+
* that would be invisible to HTTP-only crawlers.
|
|
6
|
+
*/
|
|
7
|
+
export interface WsConnection {
|
|
8
|
+
url: string;
|
|
9
|
+
framesSent: number;
|
|
10
|
+
framesReceived: number;
|
|
11
|
+
/** Sampled message bodies (first 10 per connection, truncated to 500 chars) */
|
|
12
|
+
samples: Array<{
|
|
13
|
+
direction: 'sent' | 'received';
|
|
14
|
+
payload: string;
|
|
15
|
+
ts: string;
|
|
16
|
+
}>;
|
|
17
|
+
/** Inferred protocol: 'json-rpc' | 'socket.io' | 'graphql-ws' | 'raw' */
|
|
18
|
+
protocol: string;
|
|
19
|
+
openedAt: string;
|
|
20
|
+
}
|
|
21
|
+
export declare function attachWebSocketDetector(cdpSession: any): {
|
|
22
|
+
getConnections(): WsConnection[];
|
|
23
|
+
detach(): void;
|
|
24
|
+
};
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
function inferProtocol(sample) {
|
|
2
|
+
try {
|
|
3
|
+
const obj = JSON.parse(sample);
|
|
4
|
+
if (obj?.jsonrpc) return "json-rpc";
|
|
5
|
+
if (obj?.type === "connection_init" || obj?.type === "subscribe") return "graphql-ws";
|
|
6
|
+
if (typeof obj?.sid === "string" || sample.startsWith("0{")) return "socket.io";
|
|
7
|
+
return "json";
|
|
8
|
+
} catch {
|
|
9
|
+
return "raw";
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
function attachWebSocketDetector(cdpSession) {
|
|
13
|
+
const connections = /* @__PURE__ */ new Map();
|
|
14
|
+
const onCreated = (evt) => {
|
|
15
|
+
const { requestId, url, timestamp } = evt ?? {};
|
|
16
|
+
if (!requestId || !url) return;
|
|
17
|
+
connections.set(requestId, {
|
|
18
|
+
url,
|
|
19
|
+
framesSent: 0,
|
|
20
|
+
framesReceived: 0,
|
|
21
|
+
samples: [],
|
|
22
|
+
protocol: "unknown",
|
|
23
|
+
openedAt: new Date(timestamp * 1e3).toISOString()
|
|
24
|
+
});
|
|
25
|
+
};
|
|
26
|
+
const onFrame = (evt, direction) => {
|
|
27
|
+
const { requestId, response } = evt ?? {};
|
|
28
|
+
if (!requestId) return;
|
|
29
|
+
const conn = connections.get(requestId);
|
|
30
|
+
if (!conn) return;
|
|
31
|
+
if (direction === "sent") conn.framesSent++;
|
|
32
|
+
else conn.framesReceived++;
|
|
33
|
+
if (conn.samples.length < 10 && response?.payloadData) {
|
|
34
|
+
const payload = String(response.payloadData).slice(0, 500);
|
|
35
|
+
conn.samples.push({ direction, payload, ts: (/* @__PURE__ */ new Date()).toISOString() });
|
|
36
|
+
if (conn.protocol === "unknown") conn.protocol = inferProtocol(payload);
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
const onSent = (evt) => onFrame(evt, "sent");
|
|
40
|
+
const onReceived = (evt) => onFrame(evt, "received");
|
|
41
|
+
if (cdpSession?.on) {
|
|
42
|
+
cdpSession.on("Network.webSocketCreated", onCreated);
|
|
43
|
+
cdpSession.on("Network.webSocketFrameSent", onSent);
|
|
44
|
+
cdpSession.on("Network.webSocketFrameReceived", onReceived);
|
|
45
|
+
}
|
|
46
|
+
return {
|
|
47
|
+
getConnections: () => [...connections.values()],
|
|
48
|
+
detach() {
|
|
49
|
+
if (cdpSession?.off) {
|
|
50
|
+
cdpSession.off("Network.webSocketCreated", onCreated);
|
|
51
|
+
cdpSession.off("Network.webSocketFrameSent", onSent);
|
|
52
|
+
cdpSession.off("Network.webSocketFrameReceived", onReceived);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
export {
|
|
58
|
+
attachWebSocketDetector
|
|
59
|
+
};
|
|
60
|
+
//# sourceMappingURL=websocket-detector.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../src/websocket-detector.ts"],
|
|
4
|
+
"sourcesContent": ["/**\n * WebSocket detection \u2014 intercepts WS connections opened during page crawl.\n * Records connection URLs, message schemas, and message counts.\n * This lets ZeTa discover real-time API contracts (chat, notifications, presence)\n * that would be invisible to HTTP-only crawlers.\n */\n\nexport interface WsConnection {\n url: string;\n framesSent: number;\n framesReceived: number;\n /** Sampled message bodies (first 10 per connection, truncated to 500 chars) */\n samples: Array<{ direction: 'sent' | 'received'; payload: string; ts: string }>;\n /** Inferred protocol: 'json-rpc' | 'socket.io' | 'graphql-ws' | 'raw' */\n protocol: string;\n openedAt: string;\n}\n\nfunction inferProtocol(sample: string): string {\n try {\n const obj = JSON.parse(sample);\n if (obj?.jsonrpc) return 'json-rpc';\n if (obj?.type === 'connection_init' || obj?.type === 'subscribe') return 'graphql-ws';\n if (typeof obj?.sid === 'string' || sample.startsWith('0{')) return 'socket.io';\n return 'json';\n } catch {\n return 'raw';\n }\n}\n\nexport function attachWebSocketDetector(cdpSession: any): {\n getConnections(): WsConnection[];\n detach(): void;\n} {\n const connections = new Map<string, WsConnection>();\n\n const onCreated = (evt: any) => {\n const { requestId, url, timestamp } = evt ?? {};\n if (!requestId || !url) return;\n connections.set(requestId, {\n url,\n framesSent: 0,\n framesReceived: 0,\n samples: [],\n protocol: 'unknown',\n openedAt: new Date(timestamp * 1000).toISOString(),\n });\n };\n\n const onFrame = (evt: any, direction: 'sent' | 'received') => {\n const { requestId, response } = evt ?? {};\n if (!requestId) return;\n const conn = connections.get(requestId);\n if (!conn) return;\n if (direction === 'sent') conn.framesSent++;\n else conn.framesReceived++;\n if (conn.samples.length < 10 && response?.payloadData) {\n const payload = String(response.payloadData).slice(0, 500);\n conn.samples.push({ direction, payload, ts: new Date().toISOString() });\n if (conn.protocol === 'unknown') conn.protocol = inferProtocol(payload);\n }\n };\n\n const onSent = (evt: any) => onFrame(evt, 'sent');\n const onReceived = (evt: any) => onFrame(evt, 'received');\n\n if (cdpSession?.on) {\n cdpSession.on('Network.webSocketCreated', onCreated);\n cdpSession.on('Network.webSocketFrameSent', onSent);\n cdpSession.on('Network.webSocketFrameReceived', onReceived);\n }\n\n return {\n getConnections: () => [...connections.values()],\n detach() {\n if (cdpSession?.off) {\n cdpSession.off('Network.webSocketCreated', onCreated);\n cdpSession.off('Network.webSocketFrameSent', onSent);\n cdpSession.off('Network.webSocketFrameReceived', onReceived);\n }\n },\n };\n}\n"],
|
|
5
|
+
"mappings": "AAkBA,SAAS,cAAc,QAAwB;AAC7C,MAAI;AACF,UAAM,MAAM,KAAK,MAAM,MAAM;AAC7B,QAAI,KAAK,QAAS,QAAO;AACzB,QAAI,KAAK,SAAS,qBAAqB,KAAK,SAAS,YAAa,QAAO;AACzE,QAAI,OAAO,KAAK,QAAQ,YAAY,OAAO,WAAW,IAAI,EAAG,QAAO;AACpE,WAAO;AAAA,EACT,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAEO,SAAS,wBAAwB,YAGtC;AACA,QAAM,cAAc,oBAAI,IAA0B;AAElD,QAAM,YAAY,CAAC,QAAa;AAC9B,UAAM,EAAE,WAAW,KAAK,UAAU,IAAI,OAAO,CAAC;AAC9C,QAAI,CAAC,aAAa,CAAC,IAAK;AACxB,gBAAY,IAAI,WAAW;AAAA,MACzB;AAAA,MACA,YAAY;AAAA,MACZ,gBAAgB;AAAA,MAChB,SAAS,CAAC;AAAA,MACV,UAAU;AAAA,MACV,UAAU,IAAI,KAAK,YAAY,GAAI,EAAE,YAAY;AAAA,IACnD,CAAC;AAAA,EACH;AAEA,QAAM,UAAU,CAAC,KAAU,cAAmC;AAC5D,UAAM,EAAE,WAAW,SAAS,IAAI,OAAO,CAAC;AACxC,QAAI,CAAC,UAAW;AAChB,UAAM,OAAO,YAAY,IAAI,SAAS;AACtC,QAAI,CAAC,KAAM;AACX,QAAI,cAAc,OAAQ,MAAK;AAAA,QAC1B,MAAK;AACV,QAAI,KAAK,QAAQ,SAAS,MAAM,UAAU,aAAa;AACrD,YAAM,UAAU,OAAO,SAAS,WAAW,EAAE,MAAM,GAAG,GAAG;AACzD,WAAK,QAAQ,KAAK,EAAE,WAAW,SAAS,KAAI,oBAAI,KAAK,GAAE,YAAY,EAAE,CAAC;AACtE,UAAI,KAAK,aAAa,UAAW,MAAK,WAAW,cAAc,OAAO;AAAA,IACxE;AAAA,EACF;AAEA,QAAM,SAAS,CAAC,QAAa,QAAQ,KAAK,MAAM;AAChD,QAAM,aAAa,CAAC,QAAa,QAAQ,KAAK,UAAU;AAExD,MAAI,YAAY,IAAI;AAClB,eAAW,GAAG,4BAA4B,SAAS;AACnD,eAAW,GAAG,8BAA8B,MAAM;AAClD,eAAW,GAAG,kCAAkC,UAAU;AAAA,EAC5D;AAEA,SAAO;AAAA,IACL,gBAAgB,MAAM,CAAC,GAAG,YAAY,OAAO,CAAC;AAAA,IAC9C,SAAS;AACP,UAAI,YAAY,KAAK;AACnB,mBAAW,IAAI,4BAA4B,SAAS;AACpD,mBAAW,IAAI,8BAA8B,MAAM;AACnD,mBAAW,IAAI,kCAAkC,UAAU;AAAA,MAC7D;AAAA,IACF;AAAA,EACF;AACF;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
package/dist/worker.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/worker.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { startJobWorkerLoop, processDataRetentionPurge } from "./job-queue.js";
|
|
2
|
+
import { logger } from "./logger.js";
|
|
3
|
+
async function main() {
|
|
4
|
+
logger.info("[worker] starting standalone crawler worker process");
|
|
5
|
+
startJobWorkerLoop();
|
|
6
|
+
const runPurge = () => {
|
|
7
|
+
processDataRetentionPurge().catch((err) => {
|
|
8
|
+
logger.error({ err: String(err) }, "[worker] data-retention purge failed");
|
|
9
|
+
});
|
|
10
|
+
};
|
|
11
|
+
runPurge();
|
|
12
|
+
setInterval(runPurge, 24 * 60 * 60 * 1e3);
|
|
13
|
+
if (process.env.JOB_QUEUE_BACKEND === "sqs") {
|
|
14
|
+
const { startSqsWorker } = await import("./sqs-queue.js");
|
|
15
|
+
startSqsWorker().catch((err) => {
|
|
16
|
+
logger.error({ err: String(err) }, "[worker] SQS worker crashed");
|
|
17
|
+
process.exit(1);
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
process.on("SIGTERM", () => {
|
|
22
|
+
logger.info("[worker] SIGTERM received, exiting");
|
|
23
|
+
process.exit(0);
|
|
24
|
+
});
|
|
25
|
+
process.on("SIGINT", () => {
|
|
26
|
+
logger.info("[worker] SIGINT received, exiting");
|
|
27
|
+
process.exit(0);
|
|
28
|
+
});
|
|
29
|
+
main().catch((err) => {
|
|
30
|
+
logger.error({ err: String(err) }, "[worker] fatal startup error");
|
|
31
|
+
process.exit(1);
|
|
32
|
+
});
|
|
33
|
+
//# sourceMappingURL=worker.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../src/worker.ts"],
|
|
4
|
+
"sourcesContent": ["/**\n * Standalone worker process entrypoint for a dedicated crawler/worker container,\n * separate from the API server (see infrastructure/ecs-task-definitions/crawler.json).\n *\n * index.ts is a side-effect-free library barrel imported by @qip/api \u2014 it must NOT\n * start any background loop itself, or importing a single helper from @qip/crawler\n * would silently spin up a second worker inside the API process too. This file is\n * the actual entrypoint that keeps a dedicated worker container's process alive and\n * consuming jobs: `node dist/worker.js`.\n */\nimport { startJobWorkerLoop, processDataRetentionPurge } from './job-queue.js';\nimport { logger } from './logger.js';\n\nasync function main() {\n logger.info('[worker] starting standalone crawler worker process');\n\n // Durable DB-backed queue \u2014 claims every non-CRAWL job type, and CRAWL too when\n // JOB_QUEUE_BACKEND isn't 'sqs'. Safe to run in many worker replicas at once.\n startJobWorkerLoop();\n\n // Daily data-retention purge: runs immediately on startup then every 24 h.\n // Only runs in the dedicated worker container (this file), not in the API\n // process, so there is exactly one purge sweep regardless of API replica count.\n const runPurge = () => {\n processDataRetentionPurge().catch((err) => {\n logger.error({ err: String(err) }, '[worker] data-retention purge failed');\n });\n };\n runPurge();\n setInterval(runPurge, 24 * 60 * 60 * 1000);\n\n // Optional: also consume the SQS transport for horizontally-scaled CRAWL fleets.\n if (process.env.JOB_QUEUE_BACKEND === 'sqs') {\n const { startSqsWorker } = await import('./sqs-queue.js');\n startSqsWorker().catch((err) => {\n logger.error({ err: String(err) }, '[worker] SQS worker crashed');\n process.exit(1);\n });\n }\n}\n\nprocess.on('SIGTERM', () => { logger.info('[worker] SIGTERM received, exiting'); process.exit(0); });\nprocess.on('SIGINT', () => { logger.info('[worker] SIGINT received, exiting'); process.exit(0); });\n\nmain().catch((err) => {\n logger.error({ err: String(err) }, '[worker] fatal startup error');\n process.exit(1);\n});\n"],
|
|
5
|
+
"mappings": "AAUA,SAAS,oBAAoB,iCAAiC;AAC9D,SAAS,cAAc;AAEvB,eAAe,OAAO;AACpB,SAAO,KAAK,qDAAqD;AAIjE,qBAAmB;AAKnB,QAAM,WAAW,MAAM;AACrB,8BAA0B,EAAE,MAAM,CAAC,QAAQ;AACzC,aAAO,MAAM,EAAE,KAAK,OAAO,GAAG,EAAE,GAAG,sCAAsC;AAAA,IAC3E,CAAC;AAAA,EACH;AACA,WAAS;AACT,cAAY,UAAU,KAAK,KAAK,KAAK,GAAI;AAGzC,MAAI,QAAQ,IAAI,sBAAsB,OAAO;AAC3C,UAAM,EAAE,eAAe,IAAI,MAAM,OAAO,gBAAgB;AACxD,mBAAe,EAAE,MAAM,CAAC,QAAQ;AAC9B,aAAO,MAAM,EAAE,KAAK,OAAO,GAAG,EAAE,GAAG,6BAA6B;AAChE,cAAQ,KAAK,CAAC;AAAA,IAChB,CAAC;AAAA,EACH;AACF;AAEA,QAAQ,GAAG,WAAW,MAAM;AAAE,SAAO,KAAK,oCAAoC;AAAG,UAAQ,KAAK,CAAC;AAAG,CAAC;AACnG,QAAQ,GAAG,UAAU,MAAM;AAAE,SAAO,KAAK,mCAAmC;AAAG,UAAQ,KAAK,CAAC;AAAG,CAAC;AAEjG,KAAK,EAAE,MAAM,CAAC,QAAQ;AACpB,SAAO,MAAM,EAAE,KAAK,OAAO,GAAG,EAAE,GAAG,8BAA8B;AACjE,UAAQ,KAAK,CAAC;AAChB,CAAC;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@detiq/crawler",
|
|
3
|
+
"version": "0.1.3",
|
|
4
|
+
"private": false,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"types": "types/index.d.ts",
|
|
8
|
+
"scripts": {
|
|
9
|
+
"build": "node -e \"const {execSync}=require('child_process'),{readdirSync,statSync}=require('fs'),path=require('path');function walk(d){let f=[];for(const e of readdirSync(d)){const p=path.join(d,e);statSync(p).isDirectory()?f.push(...walk(p)):p.endsWith('.ts')&&!p.endsWith('.test.ts')&&!p.endsWith('.d.ts')&&f.push(p);}return f;}const files=walk('src').join(' ');execSync('esbuild '+files+' --format=esm --platform=node --target=node20 --outdir=dist --sourcemap',{stdio:'inherit'});\" && tsc -d --emitDeclarationOnly --outDir types",
|
|
10
|
+
"typecheck": "tsc --noEmit",
|
|
11
|
+
"test": "tsx --test src/**/*.test.ts"
|
|
12
|
+
},
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"@aws-sdk/client-sqs": "^3.1075.0",
|
|
15
|
+
"@browserbasehq/stagehand": "^3.6.0",
|
|
16
|
+
"@mozilla/readability": "^0.6.0",
|
|
17
|
+
"@detiq/agents": "*",
|
|
18
|
+
"@detiq/app-brain": "*",
|
|
19
|
+
"@detiq/core": "*",
|
|
20
|
+
"@detiq/database": "*",
|
|
21
|
+
"axe-core": "^4.12.1",
|
|
22
|
+
"camoufox": "^0.1.19",
|
|
23
|
+
"fast-xml-parser": "^5.9.3",
|
|
24
|
+
"imapflow": "^1.4.3",
|
|
25
|
+
"lighthouse": "^13.4.0",
|
|
26
|
+
"linkedom": "^0.18.12",
|
|
27
|
+
"mailparser": "^3.9.12",
|
|
28
|
+
"pino": "^10.3.1",
|
|
29
|
+
"pino-pretty": "^13.1.3",
|
|
30
|
+
"playwright": "^1.55.0",
|
|
31
|
+
"playwright-extra": "^4.3.6",
|
|
32
|
+
"puppeteer-extra-plugin-stealth": "^2.11.2",
|
|
33
|
+
"rebrowser-patches": "^1.0.19",
|
|
34
|
+
"rebrowser-playwright-core": "^1.52.0",
|
|
35
|
+
"sharp": "^0.33.5",
|
|
36
|
+
"turndown": "^7.2.4",
|
|
37
|
+
"webdriverio": "^9.0.0",
|
|
38
|
+
"zod": "^3.25.0"
|
|
39
|
+
},
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"@types/axe-core": "^2.0.2",
|
|
42
|
+
"@types/mailparser": "^3.4.6",
|
|
43
|
+
"@types/turndown": "^5.0.5"
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Accessibility auditor — lightweight a11y checks using Playwright's aria snapshot
|
|
3
|
+
* and manual DOM queries. No axe-core dependency needed.
|
|
4
|
+
*
|
|
5
|
+
* Checks:
|
|
6
|
+
* 1. Images without alt text
|
|
7
|
+
* 2. Form inputs without associated labels
|
|
8
|
+
* 3. Buttons/links with no accessible name
|
|
9
|
+
* 4. Missing lang attribute on html element
|
|
10
|
+
* 5. Missing page title
|
|
11
|
+
* 6. Empty headings
|
|
12
|
+
* 7. Color contrast (limited — detects text with inline style color only)
|
|
13
|
+
* 8. Keyboard-unfocusable interactive elements (tabIndex=-1 on buttons)
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { Page } from 'playwright';
|
|
17
|
+
|
|
18
|
+
export interface A11yViolation {
|
|
19
|
+
rule: string;
|
|
20
|
+
severity: 'critical' | 'serious' | 'moderate' | 'minor';
|
|
21
|
+
count: number;
|
|
22
|
+
examples: string[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface A11yAuditResult {
|
|
26
|
+
url: string;
|
|
27
|
+
violations: A11yViolation[];
|
|
28
|
+
passCount: number;
|
|
29
|
+
score: number; // 0-100
|
|
30
|
+
auditedAt: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function queryAll(page: Page, selector: string): Promise<any[]> {
|
|
34
|
+
try {
|
|
35
|
+
return await page.$$eval(selector, els => els.map((el: any) => ({
|
|
36
|
+
outerHTML: el.outerHTML?.slice(0, 200) ?? '',
|
|
37
|
+
textContent: (el.textContent ?? '').trim().slice(0, 100),
|
|
38
|
+
tabIndex: el.tabIndex,
|
|
39
|
+
getAttribute: (a: string) => el.getAttribute(a),
|
|
40
|
+
})));
|
|
41
|
+
} catch {
|
|
42
|
+
return [];
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function auditAccessibility(page: Page, url: string): Promise<A11yAuditResult> {
|
|
47
|
+
const violations: A11yViolation[] = [];
|
|
48
|
+
let passCount = 0;
|
|
49
|
+
const auditedAt = new Date().toISOString();
|
|
50
|
+
|
|
51
|
+
// 1. Images without alt
|
|
52
|
+
const imgs = await page.$$eval('img:not([alt])', els => els.map((e: any) => e.outerHTML?.slice(0, 120) ?? ''));
|
|
53
|
+
if (imgs.length > 0) violations.push({ rule: 'image-alt', severity: 'critical', count: imgs.length, examples: imgs.slice(0, 3) });
|
|
54
|
+
else passCount++;
|
|
55
|
+
|
|
56
|
+
// 2. Inputs without labels
|
|
57
|
+
const unlabeledInputs = await page.$$eval(
|
|
58
|
+
'input:not([type="hidden"]):not([type="submit"]):not([type="button"]):not([aria-label]):not([aria-labelledby]):not([id])',
|
|
59
|
+
els => els.map((e: any) => e.outerHTML?.slice(0, 120) ?? ''),
|
|
60
|
+
);
|
|
61
|
+
if (unlabeledInputs.length > 0) violations.push({ rule: 'label', severity: 'serious', count: unlabeledInputs.length, examples: unlabeledInputs.slice(0, 3) });
|
|
62
|
+
else passCount++;
|
|
63
|
+
|
|
64
|
+
// 3. Buttons with no text
|
|
65
|
+
const emptyButtons = await page.$$eval(
|
|
66
|
+
'button:not([aria-label]):not([aria-labelledby])',
|
|
67
|
+
els => els.filter((e: any) => !(e.textContent ?? '').trim()).map((e: any) => e.outerHTML?.slice(0, 120) ?? ''),
|
|
68
|
+
);
|
|
69
|
+
if (emptyButtons.length > 0) violations.push({ rule: 'button-name', severity: 'critical', count: emptyButtons.length, examples: emptyButtons.slice(0, 3) });
|
|
70
|
+
else passCount++;
|
|
71
|
+
|
|
72
|
+
// 4. Missing html lang
|
|
73
|
+
const htmlLang = await page.$eval('html', (el: any) => el.getAttribute('lang') ?? '').catch(() => '');
|
|
74
|
+
if (!htmlLang) violations.push({ rule: 'html-has-lang', severity: 'serious', count: 1, examples: ['<html> missing lang attribute'] });
|
|
75
|
+
else passCount++;
|
|
76
|
+
|
|
77
|
+
// 5. Missing page title
|
|
78
|
+
const title = await page.title().catch(() => '');
|
|
79
|
+
if (!title) violations.push({ rule: 'document-title', severity: 'serious', count: 1, examples: ['Page has no <title>'] });
|
|
80
|
+
else passCount++;
|
|
81
|
+
|
|
82
|
+
// 6. Empty headings
|
|
83
|
+
const emptyHeadings = await page.$$eval(
|
|
84
|
+
'h1,h2,h3,h4,h5,h6',
|
|
85
|
+
els => els.filter((e: any) => !(e.textContent ?? '').trim()).map((e: any) => e.tagName.toLowerCase()),
|
|
86
|
+
);
|
|
87
|
+
if (emptyHeadings.length > 0) violations.push({ rule: 'empty-heading', severity: 'moderate', count: emptyHeadings.length, examples: emptyHeadings.slice(0, 3) });
|
|
88
|
+
else passCount++;
|
|
89
|
+
|
|
90
|
+
// 7. Links with no accessible name
|
|
91
|
+
const emptyLinks = await page.$$eval(
|
|
92
|
+
'a[href]:not([aria-label]):not([aria-labelledby])',
|
|
93
|
+
els => els.filter((e: any) => !(e.textContent ?? '').trim() && !e.querySelector('img[alt]')).map((e: any) => e.outerHTML?.slice(0, 120) ?? ''),
|
|
94
|
+
);
|
|
95
|
+
if (emptyLinks.length > 0) violations.push({ rule: 'link-name', severity: 'serious', count: emptyLinks.length, examples: emptyLinks.slice(0, 3) });
|
|
96
|
+
else passCount++;
|
|
97
|
+
|
|
98
|
+
const totalChecks = passCount + violations.length;
|
|
99
|
+
const score = totalChecks > 0 ? Math.round((passCount / totalChecks) * 100) : 100;
|
|
100
|
+
|
|
101
|
+
return { url, violations, passCount, score, auditedAt };
|
|
102
|
+
}
|