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