@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,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* P1: URL Pattern LLM Completion
|
|
3
|
+
*
|
|
4
|
+
* After BFS crawl completes, runs a single LLM call to predict URLs that exist
|
|
5
|
+
* but were never linked from within the app. Validates each candidate with
|
|
6
|
+
* HTTP HEAD before emitting as crawl evidence (URL_CANDIDATES).
|
|
7
|
+
*
|
|
8
|
+
* Cost: ~1 LLM call + N HEAD requests. Takes ~5-30 seconds depending on
|
|
9
|
+
* candidate count. Does NOT launch a browser.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { logger as rootLogger } from './logger.js';
|
|
13
|
+
|
|
14
|
+
const logger = rootLogger.child({ module: 'url-pattern-completer' });
|
|
15
|
+
|
|
16
|
+
export interface UrlCandidate {
|
|
17
|
+
url: string;
|
|
18
|
+
confidence: number;
|
|
19
|
+
reason: string;
|
|
20
|
+
status: 'hit' | 'miss' | 'error';
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface LlmCfg {
|
|
24
|
+
provider: string;
|
|
25
|
+
model: string;
|
|
26
|
+
apiKey?: string;
|
|
27
|
+
baseUrl?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Returns validated URL candidates (HEAD=200) predicted by LLM from
|
|
32
|
+
* the pattern of already-discovered URLs.
|
|
33
|
+
*/
|
|
34
|
+
export async function runUrlPatternCompletion(
|
|
35
|
+
discoveredUrls: string[],
|
|
36
|
+
appUrl: string,
|
|
37
|
+
llmCfg: LlmCfg | null | undefined,
|
|
38
|
+
): Promise<UrlCandidate[]> {
|
|
39
|
+
if (!llmCfg?.apiKey || discoveredUrls.length < 2) return [];
|
|
40
|
+
|
|
41
|
+
const origin = (() => { try { return new URL(appUrl).origin; } catch { return ''; } })();
|
|
42
|
+
if (!origin) return [];
|
|
43
|
+
|
|
44
|
+
// Only send internal URLs (same origin, no assets)
|
|
45
|
+
const ASSET_EXT = /\.(png|jpg|jpeg|gif|svg|ico|webp|woff|woff2|ttf|eot|css|js|map|json|xml|txt|pdf)$/i;
|
|
46
|
+
const internalUrls = discoveredUrls
|
|
47
|
+
.filter(u => { try { return new URL(u).origin === origin && !ASSET_EXT.test(u); } catch { return false; } })
|
|
48
|
+
.slice(0, 60); // cap context size
|
|
49
|
+
|
|
50
|
+
if (internalUrls.length < 2) return [];
|
|
51
|
+
|
|
52
|
+
const prompt = `You are analyzing a web application's URL structure to find screens that exist but weren't discovered during automated crawling.
|
|
53
|
+
|
|
54
|
+
Discovered URLs:
|
|
55
|
+
${internalUrls.map(u => { try { return new URL(u).pathname + new URL(u).search; } catch { return u; } }).join('\n')}
|
|
56
|
+
|
|
57
|
+
Based on these URL patterns, suggest up to 15 additional URLs that likely exist in this application but weren't found — typically settings subpages, admin panels, profile variants, paginated views, or feature-gated routes.
|
|
58
|
+
|
|
59
|
+
Rules:
|
|
60
|
+
- Only suggest URLs on the same domain
|
|
61
|
+
- Use relative paths (starting with /)
|
|
62
|
+
- Skip generic guesses (/login, /register, /home) unless the pattern strongly implies them
|
|
63
|
+
- Focus on patterns YOU SEE in the discovered URLs (e.g. if /settings/profile exists, try /settings/security, /settings/billing)
|
|
64
|
+
- Confidence 0.0–1.0: how certain you are the page exists
|
|
65
|
+
|
|
66
|
+
Respond with ONLY valid JSON, no explanation:
|
|
67
|
+
{"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"}]}`;
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
const Anthropic = (await import('@anthropic-ai/sdk')).default;
|
|
71
|
+
const client = new Anthropic({ apiKey: llmCfg.apiKey });
|
|
72
|
+
const response = await client.messages.create({
|
|
73
|
+
model: llmCfg.model ?? 'claude-haiku-4-5-20251001',
|
|
74
|
+
max_tokens: 1024,
|
|
75
|
+
// @ts-ignore: cache_control supported in beta
|
|
76
|
+
system: [{ type: 'text', text: 'You are a URL pattern analyst. Always respond with valid JSON only.', cache_control: { type: 'ephemeral' } }],
|
|
77
|
+
messages: [{ role: 'user', content: prompt }],
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
const text = (response.content[0] as any)?.text ?? '';
|
|
81
|
+
const jsonMatch = text.match(/\{[\s\S]*\}/);
|
|
82
|
+
if (!jsonMatch) return [];
|
|
83
|
+
const parsed = JSON.parse(jsonMatch[0]);
|
|
84
|
+
const rawCandidates: Array<{ path: string; confidence: number; reason: string }> =
|
|
85
|
+
parsed.candidates ?? [];
|
|
86
|
+
|
|
87
|
+
if (!Array.isArray(rawCandidates) || rawCandidates.length === 0) return [];
|
|
88
|
+
|
|
89
|
+
// HEAD-validate each candidate
|
|
90
|
+
const results: UrlCandidate[] = [];
|
|
91
|
+
for (const c of rawCandidates.slice(0, 20)) {
|
|
92
|
+
if (!c.path || typeof c.path !== 'string') continue;
|
|
93
|
+
const fullUrl = c.path.startsWith('http') ? c.path : `${origin}${c.path.startsWith('/') ? '' : '/'}${c.path}`;
|
|
94
|
+
try {
|
|
95
|
+
const controller = new AbortController();
|
|
96
|
+
const timeout = setTimeout(() => controller.abort(), 6_000);
|
|
97
|
+
const res = await fetch(fullUrl, { method: 'HEAD', signal: controller.signal, redirect: 'follow' }).catch(() => null);
|
|
98
|
+
clearTimeout(timeout);
|
|
99
|
+
const status: UrlCandidate['status'] = res?.ok ? 'hit' : 'miss';
|
|
100
|
+
results.push({ url: fullUrl, confidence: c.confidence ?? 0.5, reason: c.reason ?? '', status });
|
|
101
|
+
logger.info({ url: fullUrl, httpStatus: res?.status ?? 'timeout', status }, '[url-completer] candidate checked');
|
|
102
|
+
} catch {
|
|
103
|
+
results.push({ url: fullUrl, confidence: c.confidence ?? 0.5, reason: c.reason ?? '', status: 'error' });
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const hits = results.filter(r => r.status === 'hit');
|
|
108
|
+
logger.info({ total: results.length, hits: hits.length }, '[url-completer] pattern completion done');
|
|
109
|
+
return results;
|
|
110
|
+
} catch (err) {
|
|
111
|
+
logger.warn({ err: String(err) }, '[url-completer] LLM call failed — skipping pattern completion');
|
|
112
|
+
return [];
|
|
113
|
+
}
|
|
114
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { pHashVerdict, mergeAiVisualVerdict } from './visual-diff-verdict.js';
|
|
4
|
+
|
|
5
|
+
test('pHashVerdict: IDENTICAL maps to NONE/APPROVE', () => {
|
|
6
|
+
const v = pHashVerdict(0, 'IDENTICAL', []);
|
|
7
|
+
assert.equal(v.changeType, 'NONE');
|
|
8
|
+
assert.equal(v.recommendation, 'APPROVE');
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test('pHashVerdict: MODERATE change level maps to SIGNIFICANT changeType', () => {
|
|
12
|
+
const v = pHashVerdict(12, 'MODERATE', []);
|
|
13
|
+
assert.equal(v.changeType, 'SIGNIFICANT');
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test('pHashVerdict: SIGNIFICANT change level maps to CRITICAL changeType (naming overlap between phash and VisualDiff scales)', () => {
|
|
17
|
+
const v = pHashVerdict(35, 'SIGNIFICANT', []);
|
|
18
|
+
assert.equal(v.changeType, 'CRITICAL');
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test('pHashVerdict: recommendation escalates with distance regardless of changeLevel', () => {
|
|
22
|
+
assert.equal(pHashVerdict(0, 'MINOR', []).recommendation, 'APPROVE');
|
|
23
|
+
assert.equal(pHashVerdict(10, 'MINOR', []).recommendation, 'REVIEW');
|
|
24
|
+
assert.equal(pHashVerdict(25, 'MINOR', []).recommendation, 'FLAG');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('mergeAiVisualVerdict: AI downgrading pHash CRITICAL to MINOR (anti-aliasing noise) produces no bug report draft', () => {
|
|
28
|
+
const pHash = pHashVerdict(35, 'SIGNIFICANT', [{ location: 'header', severity: 'Cosmetic', description: 'pixel shift' }]);
|
|
29
|
+
const merged = mergeAiVisualVerdict(
|
|
30
|
+
pHash,
|
|
31
|
+
{ changeType: 'MINOR', recommendation: 'APPROVE', changes: [], diffScore: 3 },
|
|
32
|
+
'Login Screen',
|
|
33
|
+
);
|
|
34
|
+
assert.equal(merged.changeType, 'MINOR');
|
|
35
|
+
assert.equal(merged.recommendation, 'APPROVE');
|
|
36
|
+
assert.equal(merged.bugReportDraft, undefined, 'a downgraded verdict must not carry a stale bug report draft');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('mergeAiVisualVerdict: AI confirming CRITICAL produces a bug report draft with the AI diff score, not the pHash one', () => {
|
|
40
|
+
const pHash = pHashVerdict(35, 'SIGNIFICANT', []);
|
|
41
|
+
const merged = mergeAiVisualVerdict(
|
|
42
|
+
pHash,
|
|
43
|
+
{ changeType: 'CRITICAL', recommendation: 'FLAG', changes: [{ location: 'nav', severity: 'Functional', description: 'Logo missing' }], diffScore: 62.4 },
|
|
44
|
+
'Dashboard',
|
|
45
|
+
);
|
|
46
|
+
assert.equal(merged.changeType, 'CRITICAL');
|
|
47
|
+
assert.ok(merged.bugReportDraft);
|
|
48
|
+
assert.equal(merged.bugReportDraft!.severity, 'High');
|
|
49
|
+
assert.match(merged.bugReportDraft!.actual, /62\.4%/);
|
|
50
|
+
assert.equal(merged.changes.length, 1);
|
|
51
|
+
assert.equal(merged.changes[0].description, 'Logo missing');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('mergeAiVisualVerdict: SIGNIFICANT (not just CRITICAL) also gets a bug report draft, with Medium severity', () => {
|
|
55
|
+
const pHash = pHashVerdict(20, 'MODERATE', []);
|
|
56
|
+
const merged = mergeAiVisualVerdict(
|
|
57
|
+
pHash,
|
|
58
|
+
{ changeType: 'SIGNIFICANT', recommendation: 'REVIEW', changes: [], diffScore: 18 },
|
|
59
|
+
'Checkout',
|
|
60
|
+
);
|
|
61
|
+
assert.ok(merged.bugReportDraft);
|
|
62
|
+
assert.equal(merged.bugReportDraft!.severity, 'Medium');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('mergeAiVisualVerdict: empty AI changes array keeps the pHash-derived changes instead of wiping them', () => {
|
|
66
|
+
const pHash = pHashVerdict(35, 'SIGNIFICANT', [{ location: 'footer', severity: 'Cosmetic', description: 'layout shift' }]);
|
|
67
|
+
const merged = mergeAiVisualVerdict(
|
|
68
|
+
pHash,
|
|
69
|
+
{ changeType: 'CRITICAL', recommendation: 'FLAG', changes: [], diffScore: 40 },
|
|
70
|
+
'Settings',
|
|
71
|
+
);
|
|
72
|
+
assert.equal(merged.changes.length, 1);
|
|
73
|
+
assert.equal(merged.changes[0].description, 'layout shift');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('mergeAiVisualVerdict: malformed AI result (no changeType) falls back to the pHash verdict untouched', () => {
|
|
77
|
+
const pHash = pHashVerdict(35, 'SIGNIFICANT', []);
|
|
78
|
+
const merged = mergeAiVisualVerdict(pHash, { changeType: '', recommendation: '', changes: [], diffScore: 0 }, 'Home');
|
|
79
|
+
assert.deepEqual(merged, pHash);
|
|
80
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
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 @detiq/database, which instantiates a PrismaClient at module load and
|
|
5
|
+
* so requires a live DATABASE_URL just to import the module).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export interface VisualDiffVerdict {
|
|
9
|
+
changeType: 'NONE' | 'MINOR' | 'SIGNIFICANT' | 'CRITICAL';
|
|
10
|
+
recommendation: 'APPROVE' | 'REVIEW' | 'FLAG' | 'BLOCK';
|
|
11
|
+
changes: Array<{ location: string; severity: string; description: string }>;
|
|
12
|
+
bugReportDraft?: { title: string; severity: string; steps: string[]; expected: string; actual: string };
|
|
13
|
+
aiSummary?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Cheap, always-on first-pass verdict from perceptual hash distance + layout/element diffs. */
|
|
17
|
+
export function pHashVerdict(
|
|
18
|
+
pHashDistance: number,
|
|
19
|
+
changeLevel: 'IDENTICAL' | 'MINOR' | 'MODERATE' | 'SIGNIFICANT',
|
|
20
|
+
changes: Array<{ location: string; severity: string; description: string }>,
|
|
21
|
+
): VisualDiffVerdict {
|
|
22
|
+
const changeType: VisualDiffVerdict['changeType'] = changeLevel === 'IDENTICAL' ? 'NONE'
|
|
23
|
+
: changeLevel === 'MINOR' ? 'MINOR'
|
|
24
|
+
: changeLevel === 'MODERATE' ? 'SIGNIFICANT'
|
|
25
|
+
: 'CRITICAL';
|
|
26
|
+
const recommendation: VisualDiffVerdict['recommendation'] = pHashDistance === 0 ? 'APPROVE'
|
|
27
|
+
: pHashDistance < 5 ? 'APPROVE'
|
|
28
|
+
: pHashDistance < 20 ? 'REVIEW'
|
|
29
|
+
: 'FLAG';
|
|
30
|
+
return { changeType, recommendation, changes };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Replaces a pHash-only verdict with the AI's semantic judgment once it's available,
|
|
34
|
+
* and drafts a bug report for anything still CRITICAL/SIGNIFICANT after AI review
|
|
35
|
+
* (pHash flags plenty of anti-aliasing/font-rendering noise the AI correctly discards
|
|
36
|
+
* as NONE/MINOR — only what survives AI review is worth a draft). */
|
|
37
|
+
export function mergeAiVisualVerdict(
|
|
38
|
+
pHashResult: VisualDiffVerdict,
|
|
39
|
+
aiResult: { changeType: string; changes: any[]; recommendation: string; diffScore: number; summary?: string },
|
|
40
|
+
screenName: string,
|
|
41
|
+
): VisualDiffVerdict {
|
|
42
|
+
if (!aiResult.changeType) return pHashResult;
|
|
43
|
+
const changeType = aiResult.changeType as VisualDiffVerdict['changeType'];
|
|
44
|
+
const recommendation = aiResult.recommendation as VisualDiffVerdict['recommendation'];
|
|
45
|
+
const changes = Array.isArray(aiResult.changes) && aiResult.changes.length > 0 ? aiResult.changes : pHashResult.changes;
|
|
46
|
+
const aiSummary = aiResult.summary ?? undefined;
|
|
47
|
+
const bugReportDraft = (changeType === 'CRITICAL' || changeType === 'SIGNIFICANT')
|
|
48
|
+
? {
|
|
49
|
+
title: `Visual regression on "${screenName}"`,
|
|
50
|
+
severity: changeType === 'CRITICAL' ? 'High' : 'Medium',
|
|
51
|
+
steps: ['Navigate to the screen', 'Compare current view to baseline screenshot'],
|
|
52
|
+
expected: 'Screen appearance should match the stored baseline.',
|
|
53
|
+
actual: aiSummary || `Screen has changed (diff score: ${aiResult.diffScore.toFixed(1)}%)`,
|
|
54
|
+
}
|
|
55
|
+
: undefined;
|
|
56
|
+
return { changeType, recommendation, changes, bugReportDraft, aiSummary };
|
|
57
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
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
|
+
|
|
9
|
+
export type CrawlEventType = 'crawl.started' | 'crawl.progress' | 'crawl.completed' | 'crawl.failed';
|
|
10
|
+
|
|
11
|
+
export interface WebhookPayload {
|
|
12
|
+
event: CrawlEventType;
|
|
13
|
+
jobId: string;
|
|
14
|
+
projectId: string;
|
|
15
|
+
tenantId: string;
|
|
16
|
+
timestamp: string;
|
|
17
|
+
data: Record<string, unknown>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface WebhookNotifierOpts {
|
|
21
|
+
url: string;
|
|
22
|
+
secret?: string; // HMAC-SHA256 signing secret — adds X-ZeTa-Signature header
|
|
23
|
+
headers?: Record<string, string>;
|
|
24
|
+
timeoutMs?: number; // default 10s
|
|
25
|
+
maxRetries?: number; // default 3
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function hmacSign(secret: string, payload: string): Promise<string> {
|
|
29
|
+
const key = await crypto.subtle.importKey(
|
|
30
|
+
'raw',
|
|
31
|
+
new TextEncoder().encode(secret),
|
|
32
|
+
{ name: 'HMAC', hash: 'SHA-256' },
|
|
33
|
+
false,
|
|
34
|
+
['sign'],
|
|
35
|
+
);
|
|
36
|
+
const sig = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(payload));
|
|
37
|
+
return Array.from(new Uint8Array(sig)).map(b => b.toString(16).padStart(2, '0')).join('');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export class WebhookNotifier {
|
|
41
|
+
private readonly opts: Required<Omit<WebhookNotifierOpts, 'secret'>> & { secret?: string };
|
|
42
|
+
|
|
43
|
+
constructor(opts: WebhookNotifierOpts) {
|
|
44
|
+
this.opts = {
|
|
45
|
+
url: opts.url,
|
|
46
|
+
secret: opts.secret,
|
|
47
|
+
headers: opts.headers ?? {},
|
|
48
|
+
timeoutMs: opts.timeoutMs ?? 10_000,
|
|
49
|
+
maxRetries: opts.maxRetries ?? 3,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async notify(
|
|
54
|
+
event: CrawlEventType,
|
|
55
|
+
jobId: string,
|
|
56
|
+
projectId: string,
|
|
57
|
+
tenantId: string,
|
|
58
|
+
data: Record<string, unknown> = {},
|
|
59
|
+
): Promise<void> {
|
|
60
|
+
const payload: WebhookPayload = {
|
|
61
|
+
event,
|
|
62
|
+
jobId,
|
|
63
|
+
projectId,
|
|
64
|
+
tenantId,
|
|
65
|
+
timestamp: new Date().toISOString(),
|
|
66
|
+
data,
|
|
67
|
+
};
|
|
68
|
+
const body = JSON.stringify(payload);
|
|
69
|
+
const headers: Record<string, string> = {
|
|
70
|
+
'Content-Type': 'application/json',
|
|
71
|
+
'User-Agent': 'ZeTa-Crawler/1.0',
|
|
72
|
+
...this.opts.headers,
|
|
73
|
+
};
|
|
74
|
+
if (this.opts.secret) {
|
|
75
|
+
headers['X-ZeTa-Signature'] = `sha256=${await hmacSign(this.opts.secret, body)}`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
let lastErr: any;
|
|
79
|
+
for (let attempt = 0; attempt <= this.opts.maxRetries; attempt++) {
|
|
80
|
+
try {
|
|
81
|
+
const res = await fetch(this.opts.url, {
|
|
82
|
+
method: 'POST',
|
|
83
|
+
headers,
|
|
84
|
+
body,
|
|
85
|
+
signal: AbortSignal.timeout(this.opts.timeoutMs),
|
|
86
|
+
});
|
|
87
|
+
if (res.ok) return;
|
|
88
|
+
lastErr = new Error(`Webhook HTTP ${res.status}`);
|
|
89
|
+
} catch (e) {
|
|
90
|
+
lastErr = e;
|
|
91
|
+
}
|
|
92
|
+
if (attempt < this.opts.maxRetries) {
|
|
93
|
+
await new Promise(r => setTimeout(r, 1_000 * Math.pow(2, attempt)));
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
console.error(`[webhook] Failed to deliver ${event} after ${this.opts.maxRetries + 1} attempts:`, lastErr?.message);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Slack-compatible message format */
|
|
100
|
+
static slackPayload(event: CrawlEventType, data: Record<string, unknown>): Record<string, unknown> {
|
|
101
|
+
const emoji = event === 'crawl.completed' ? '✅' : event === 'crawl.failed' ? '❌' : '🔄';
|
|
102
|
+
return {
|
|
103
|
+
text: `${emoji} *ZeTa Crawl Event: ${event}*`,
|
|
104
|
+
blocks: [
|
|
105
|
+
{ type: 'section', text: { type: 'mrkdwn', text: `${emoji} *${event}*\n${Object.entries(data).map(([k, v]) => `• *${k}*: ${v}`).join('\n')}` } },
|
|
106
|
+
],
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
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
|
+
|
|
8
|
+
export interface WsConnection {
|
|
9
|
+
url: string;
|
|
10
|
+
framesSent: number;
|
|
11
|
+
framesReceived: number;
|
|
12
|
+
/** Sampled message bodies (first 10 per connection, truncated to 500 chars) */
|
|
13
|
+
samples: Array<{ direction: 'sent' | 'received'; payload: string; ts: string }>;
|
|
14
|
+
/** Inferred protocol: 'json-rpc' | 'socket.io' | 'graphql-ws' | 'raw' */
|
|
15
|
+
protocol: string;
|
|
16
|
+
openedAt: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function inferProtocol(sample: string): string {
|
|
20
|
+
try {
|
|
21
|
+
const obj = JSON.parse(sample);
|
|
22
|
+
if (obj?.jsonrpc) return 'json-rpc';
|
|
23
|
+
if (obj?.type === 'connection_init' || obj?.type === 'subscribe') return 'graphql-ws';
|
|
24
|
+
if (typeof obj?.sid === 'string' || sample.startsWith('0{')) return 'socket.io';
|
|
25
|
+
return 'json';
|
|
26
|
+
} catch {
|
|
27
|
+
return 'raw';
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function attachWebSocketDetector(cdpSession: any): {
|
|
32
|
+
getConnections(): WsConnection[];
|
|
33
|
+
detach(): void;
|
|
34
|
+
} {
|
|
35
|
+
const connections = new Map<string, WsConnection>();
|
|
36
|
+
|
|
37
|
+
const onCreated = (evt: any) => {
|
|
38
|
+
const { requestId, url, timestamp } = evt ?? {};
|
|
39
|
+
if (!requestId || !url) return;
|
|
40
|
+
connections.set(requestId, {
|
|
41
|
+
url,
|
|
42
|
+
framesSent: 0,
|
|
43
|
+
framesReceived: 0,
|
|
44
|
+
samples: [],
|
|
45
|
+
protocol: 'unknown',
|
|
46
|
+
openedAt: new Date(timestamp * 1000).toISOString(),
|
|
47
|
+
});
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const onFrame = (evt: any, direction: 'sent' | 'received') => {
|
|
51
|
+
const { requestId, response } = evt ?? {};
|
|
52
|
+
if (!requestId) return;
|
|
53
|
+
const conn = connections.get(requestId);
|
|
54
|
+
if (!conn) return;
|
|
55
|
+
if (direction === 'sent') conn.framesSent++;
|
|
56
|
+
else conn.framesReceived++;
|
|
57
|
+
if (conn.samples.length < 10 && response?.payloadData) {
|
|
58
|
+
const payload = String(response.payloadData).slice(0, 500);
|
|
59
|
+
conn.samples.push({ direction, payload, ts: new Date().toISOString() });
|
|
60
|
+
if (conn.protocol === 'unknown') conn.protocol = inferProtocol(payload);
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const onSent = (evt: any) => onFrame(evt, 'sent');
|
|
65
|
+
const onReceived = (evt: any) => onFrame(evt, 'received');
|
|
66
|
+
|
|
67
|
+
if (cdpSession?.on) {
|
|
68
|
+
cdpSession.on('Network.webSocketCreated', onCreated);
|
|
69
|
+
cdpSession.on('Network.webSocketFrameSent', onSent);
|
|
70
|
+
cdpSession.on('Network.webSocketFrameReceived', onReceived);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
getConnections: () => [...connections.values()],
|
|
75
|
+
detach() {
|
|
76
|
+
if (cdpSession?.off) {
|
|
77
|
+
cdpSession.off('Network.webSocketCreated', onCreated);
|
|
78
|
+
cdpSession.off('Network.webSocketFrameSent', onSent);
|
|
79
|
+
cdpSession.off('Network.webSocketFrameReceived', onReceived);
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|
package/src/worker.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Standalone worker process entrypoint for a dedicated crawler/worker container,
|
|
3
|
+
* separate from the API server (see infrastructure/ecs-task-definitions/crawler.json).
|
|
4
|
+
*
|
|
5
|
+
* index.ts is a side-effect-free library barrel imported by @detiq/api — it must NOT
|
|
6
|
+
* start any background loop itself, or importing a single helper from @detiq/crawler
|
|
7
|
+
* would silently spin up a second worker inside the API process too. This file is
|
|
8
|
+
* the actual entrypoint that keeps a dedicated worker container's process alive and
|
|
9
|
+
* consuming jobs: `node dist/worker.js`.
|
|
10
|
+
*/
|
|
11
|
+
import { startJobWorkerLoop, processDataRetentionPurge } from './job-queue.js';
|
|
12
|
+
import { logger } from './logger.js';
|
|
13
|
+
|
|
14
|
+
async function main() {
|
|
15
|
+
logger.info('[worker] starting standalone crawler worker process');
|
|
16
|
+
|
|
17
|
+
// Durable DB-backed queue — claims every non-CRAWL job type, and CRAWL too when
|
|
18
|
+
// JOB_QUEUE_BACKEND isn't 'sqs'. Safe to run in many worker replicas at once.
|
|
19
|
+
startJobWorkerLoop();
|
|
20
|
+
|
|
21
|
+
// Daily data-retention purge: runs immediately on startup then every 24 h.
|
|
22
|
+
// Only runs in the dedicated worker container (this file), not in the API
|
|
23
|
+
// process, so there is exactly one purge sweep regardless of API replica count.
|
|
24
|
+
const runPurge = () => {
|
|
25
|
+
processDataRetentionPurge().catch((err) => {
|
|
26
|
+
logger.error({ err: String(err) }, '[worker] data-retention purge failed');
|
|
27
|
+
});
|
|
28
|
+
};
|
|
29
|
+
runPurge();
|
|
30
|
+
setInterval(runPurge, 24 * 60 * 60 * 1000);
|
|
31
|
+
|
|
32
|
+
// Optional: also consume the SQS transport for horizontally-scaled CRAWL fleets.
|
|
33
|
+
if (process.env.JOB_QUEUE_BACKEND === 'sqs') {
|
|
34
|
+
const { startSqsWorker } = await import('./sqs-queue.js');
|
|
35
|
+
startSqsWorker().catch((err) => {
|
|
36
|
+
logger.error({ err: String(err) }, '[worker] SQS worker crashed');
|
|
37
|
+
process.exit(1);
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
process.on('SIGTERM', () => { logger.info('[worker] SIGTERM received, exiting'); process.exit(0); });
|
|
43
|
+
process.on('SIGINT', () => { logger.info('[worker] SIGINT received, exiting'); process.exit(0); });
|
|
44
|
+
|
|
45
|
+
main().catch((err) => {
|
|
46
|
+
logger.error({ err: String(err) }, '[worker] fatal startup error');
|
|
47
|
+
process.exit(1);
|
|
48
|
+
});
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": "../../tsconfig.base.json",
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"rootDir": "src",
|
|
5
|
+
"outDir": "dist",
|
|
6
|
+
"lib": ["ES2022", "DOM"],
|
|
7
|
+
"isolatedModules": true,
|
|
8
|
+
"composite": false,
|
|
9
|
+
"noEmitOnError": false
|
|
10
|
+
},
|
|
11
|
+
"include": ["src/**/*.ts"],
|
|
12
|
+
"exclude": ["src/**/*.test.ts"]
|
|
13
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
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
|
+
import type { Page } from 'playwright';
|
|
16
|
+
export interface A11yViolation {
|
|
17
|
+
rule: string;
|
|
18
|
+
severity: 'critical' | 'serious' | 'moderate' | 'minor';
|
|
19
|
+
count: number;
|
|
20
|
+
examples: string[];
|
|
21
|
+
}
|
|
22
|
+
export interface A11yAuditResult {
|
|
23
|
+
url: string;
|
|
24
|
+
violations: A11yViolation[];
|
|
25
|
+
passCount: number;
|
|
26
|
+
score: number;
|
|
27
|
+
auditedAt: string;
|
|
28
|
+
}
|
|
29
|
+
export declare function auditAccessibility(page: Page, url: string): Promise<A11yAuditResult>;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { Stagehand } from '@browserbasehq/stagehand';
|
|
2
|
+
import type { Credentials } from './auth.js';
|
|
3
|
+
/**
|
|
4
|
+
* Recorded login step from the Chrome extension's step recorder.
|
|
5
|
+
* {username} and {password} in `value` are substituted at replay time.
|
|
6
|
+
*/
|
|
7
|
+
export interface LoginStep {
|
|
8
|
+
action: 'fill' | 'click' | 'wait';
|
|
9
|
+
selector: string;
|
|
10
|
+
value?: string;
|
|
11
|
+
ms?: number;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* AI-guided login using Stagehand act/extract.
|
|
15
|
+
*
|
|
16
|
+
* @param loginInstructions - Optional natural language description of the login flow.
|
|
17
|
+
* When provided, an initial stagehand.act() is called with these instructions to
|
|
18
|
+
* navigate the UI (e.g. "click Sign In in the nav, fill email, click Continue").
|
|
19
|
+
* The standard 3-step credential fill follows immediately after.
|
|
20
|
+
* @param opts - Optional tenant/project/job IDs for writing AUTH_STEP CrawlEvidence.
|
|
21
|
+
*/
|
|
22
|
+
export declare function loginWithStagehand(stagehand: Stagehand, creds: Credentials, startUrl: string, captchaOpts?: {
|
|
23
|
+
apiKey?: string;
|
|
24
|
+
provider?: '2captcha' | 'capsolver';
|
|
25
|
+
}, emailOtpOpts?: {
|
|
26
|
+
apiKey?: string;
|
|
27
|
+
inboxId?: string;
|
|
28
|
+
}, loginInstructions?: string, opts?: {
|
|
29
|
+
tenantId?: string;
|
|
30
|
+
projectId?: string;
|
|
31
|
+
jobId?: string;
|
|
32
|
+
}): Promise<boolean>;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auth token rotator — manage a pool of authenticated sessions for multi-account crawls.
|
|
3
|
+
*
|
|
4
|
+
* Use case: crawl the same app as admin+user+guest in one crawl job to discover
|
|
5
|
+
* role-specific screens that only appear when authenticated with different privileges.
|
|
6
|
+
* Each session is a Playwright storageState JSON string.
|
|
7
|
+
*
|
|
8
|
+
* Sessions are checked out (locked) before use and returned after the page is done.
|
|
9
|
+
* Round-robin assignment with stale-session eviction after maxAgeMs.
|
|
10
|
+
*/
|
|
11
|
+
export interface SessionEntry {
|
|
12
|
+
id: string;
|
|
13
|
+
role: string;
|
|
14
|
+
storageState: string;
|
|
15
|
+
createdAt: number;
|
|
16
|
+
checkedOutAt: number | null;
|
|
17
|
+
requestCount: number;
|
|
18
|
+
}
|
|
19
|
+
export interface SessionPoolOpts {
|
|
20
|
+
sessions: Array<{
|
|
21
|
+
role: string;
|
|
22
|
+
storageState: string;
|
|
23
|
+
}>;
|
|
24
|
+
maxAgeMs?: number;
|
|
25
|
+
maxRequestsPerSession?: number;
|
|
26
|
+
}
|
|
27
|
+
export declare class AuthTokenRotator {
|
|
28
|
+
private pool;
|
|
29
|
+
private readonly maxAgeMs;
|
|
30
|
+
private readonly maxRequests;
|
|
31
|
+
constructor(opts: SessionPoolOpts);
|
|
32
|
+
/** Get the next available session. Returns null if all are checked out. */
|
|
33
|
+
checkout(): SessionEntry | null;
|
|
34
|
+
/** Return a session to the pool after use. */
|
|
35
|
+
checkin(sessionId: string, updatedStorageState?: string): void;
|
|
36
|
+
/** Get all available sessions (for parallel crawl setup). */
|
|
37
|
+
getAll(): ReadonlyArray<SessionEntry>;
|
|
38
|
+
/** Stats for monitoring. */
|
|
39
|
+
stats(): {
|
|
40
|
+
total: number;
|
|
41
|
+
available: number;
|
|
42
|
+
checkedOut: number;
|
|
43
|
+
stale: number;
|
|
44
|
+
};
|
|
45
|
+
/** Add a new session to the pool at runtime. */
|
|
46
|
+
addSession(role: string, storageState: string): string;
|
|
47
|
+
}
|
package/types/auth.d.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { Page } from 'playwright';
|
|
2
|
+
export interface Credentials {
|
|
3
|
+
username: string;
|
|
4
|
+
password: string;
|
|
5
|
+
oneTimeCode?: string;
|
|
6
|
+
getOneTimeCode?: () => Promise<string>;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Recorded login step captured by the Chrome extension.
|
|
10
|
+
* {username} and {password} in `value` are substituted at replay time.
|
|
11
|
+
*/
|
|
12
|
+
export interface LoginStep {
|
|
13
|
+
action: 'fill' | 'click' | 'wait';
|
|
14
|
+
selector: string;
|
|
15
|
+
value?: string;
|
|
16
|
+
ms?: number;
|
|
17
|
+
}
|
|
18
|
+
export declare function login(page: Page, appUrl: string, creds: Credentials & {
|
|
19
|
+
loginRecipe?: LoginStep[];
|
|
20
|
+
}): Promise<boolean>;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser extension injection support.
|
|
3
|
+
*
|
|
4
|
+
* Chrome extensions require a persistent profile directory — incompatible
|
|
5
|
+
* with headless Docker by default. This module handles:
|
|
6
|
+
* 1. Persistent profile directory per extension set
|
|
7
|
+
* 2. Extension download from .crx URL or load from local path
|
|
8
|
+
* 3. Launch args for Playwright / Stagehand
|
|
9
|
+
*
|
|
10
|
+
* Usage:
|
|
11
|
+
* const profile = await prepareExtensionProfile(['/path/to/ext.crx']);
|
|
12
|
+
* // pass profile.userDataDir and profile.args to browser launch
|
|
13
|
+
*/
|
|
14
|
+
export interface ExtensionSource {
|
|
15
|
+
/** Local .crx file path or https:// URL to download from */
|
|
16
|
+
source: string;
|
|
17
|
+
/** Friendly name for logging */
|
|
18
|
+
name?: string;
|
|
19
|
+
}
|
|
20
|
+
export interface ExtensionProfile {
|
|
21
|
+
/** Chrome user-data-dir containing Preferences + extension unpacked dirs */
|
|
22
|
+
userDataDir: string;
|
|
23
|
+
/** Args to append to chromium launch — includes --load-extension and --disable-extensions-except */
|
|
24
|
+
args: string[];
|
|
25
|
+
/** Cleanup the profile directory when done */
|
|
26
|
+
cleanup: () => Promise<void>;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Prepare a Chrome user-data-dir with the given extensions loaded.
|
|
30
|
+
* Returns profile info + cleanup function.
|
|
31
|
+
*/
|
|
32
|
+
export declare function prepareExtensionProfile(extensions: ExtensionSource[]): Promise<ExtensionProfile>;
|
|
33
|
+
/**
|
|
34
|
+
* Ad-blocker extension profile using the uBlock Origin CRX.
|
|
35
|
+
* Reduces noise from ad networks during crawls.
|
|
36
|
+
*/
|
|
37
|
+
export declare function adBlockerProfile(): Promise<ExtensionProfile>;
|