@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,165 @@
|
|
|
1
|
+
import { hostname } from "node:os";
|
|
2
|
+
const STREAM_KEY = "zeta:crawl:stream";
|
|
3
|
+
const GROUP_NAME = "zeta:crawl:workers";
|
|
4
|
+
const MAX_LEN = 1e4;
|
|
5
|
+
function consumerId() {
|
|
6
|
+
return `zeta:worker:${hostname()}:${process.pid}`;
|
|
7
|
+
}
|
|
8
|
+
let ioredis;
|
|
9
|
+
async function getIoredis() {
|
|
10
|
+
if (!ioredis) {
|
|
11
|
+
ioredis = await import("ioredis").then((m) => m.default ?? m);
|
|
12
|
+
}
|
|
13
|
+
return ioredis;
|
|
14
|
+
}
|
|
15
|
+
async function createRedisQueue() {
|
|
16
|
+
const redisUrl = process.env.REDIS_URL;
|
|
17
|
+
if (!redisUrl) throw new Error("REDIS_URL env var required for redis queue backend");
|
|
18
|
+
const Redis = await getIoredis();
|
|
19
|
+
const redis = new Redis(redisUrl, {
|
|
20
|
+
maxRetriesPerRequest: 3,
|
|
21
|
+
enableReadyCheck: true,
|
|
22
|
+
lazyConnect: false
|
|
23
|
+
});
|
|
24
|
+
await redis.xgroup("CREATE", STREAM_KEY, GROUP_NAME, "$", "MKSTREAM").catch((e) => {
|
|
25
|
+
if (!e.message?.includes("BUSYGROUP")) throw e;
|
|
26
|
+
});
|
|
27
|
+
const consumer = consumerId();
|
|
28
|
+
return {
|
|
29
|
+
async enqueue(jobId, tenantId, projectId, payload = {}) {
|
|
30
|
+
const entryId = await redis.xadd(
|
|
31
|
+
STREAM_KEY,
|
|
32
|
+
"MAXLEN",
|
|
33
|
+
"~",
|
|
34
|
+
MAX_LEN,
|
|
35
|
+
"*",
|
|
36
|
+
"jobId",
|
|
37
|
+
jobId,
|
|
38
|
+
"tenantId",
|
|
39
|
+
tenantId,
|
|
40
|
+
"projectId",
|
|
41
|
+
projectId,
|
|
42
|
+
"payload",
|
|
43
|
+
JSON.stringify(payload),
|
|
44
|
+
"enqueuedAt",
|
|
45
|
+
(/* @__PURE__ */ new Date()).toISOString()
|
|
46
|
+
);
|
|
47
|
+
return entryId;
|
|
48
|
+
},
|
|
49
|
+
async dequeue(timeoutMs = 5e3) {
|
|
50
|
+
const pending = await redis.xreadgroup(
|
|
51
|
+
"GROUP",
|
|
52
|
+
GROUP_NAME,
|
|
53
|
+
consumer,
|
|
54
|
+
"COUNT",
|
|
55
|
+
"1",
|
|
56
|
+
"STREAMS",
|
|
57
|
+
STREAM_KEY,
|
|
58
|
+
"0"
|
|
59
|
+
) ?? [];
|
|
60
|
+
let entries = pending?.[0]?.[1] ?? [];
|
|
61
|
+
if (!entries.length) {
|
|
62
|
+
const fresh = await redis.xreadgroup(
|
|
63
|
+
"GROUP",
|
|
64
|
+
GROUP_NAME,
|
|
65
|
+
consumer,
|
|
66
|
+
"COUNT",
|
|
67
|
+
"1",
|
|
68
|
+
"BLOCK",
|
|
69
|
+
timeoutMs,
|
|
70
|
+
"STREAMS",
|
|
71
|
+
STREAM_KEY,
|
|
72
|
+
">"
|
|
73
|
+
) ?? [];
|
|
74
|
+
entries = fresh?.[0]?.[1] ?? [];
|
|
75
|
+
}
|
|
76
|
+
if (!entries.length) return null;
|
|
77
|
+
const [entryId, fields] = entries[0];
|
|
78
|
+
const obj = {};
|
|
79
|
+
for (let i = 0; i < fields.length; i += 2) {
|
|
80
|
+
obj[fields[i]] = fields[i + 1];
|
|
81
|
+
}
|
|
82
|
+
return {
|
|
83
|
+
id: entryId,
|
|
84
|
+
jobId: obj.jobId,
|
|
85
|
+
tenantId: obj.tenantId,
|
|
86
|
+
projectId: obj.projectId,
|
|
87
|
+
payload: JSON.parse(obj.payload ?? "{}"),
|
|
88
|
+
enqueuedAt: obj.enqueuedAt
|
|
89
|
+
};
|
|
90
|
+
},
|
|
91
|
+
async ack(streamEntryId) {
|
|
92
|
+
await redis.xack(STREAM_KEY, GROUP_NAME, streamEntryId);
|
|
93
|
+
},
|
|
94
|
+
async nack(streamEntryId) {
|
|
95
|
+
await redis.xack(STREAM_KEY, GROUP_NAME, streamEntryId);
|
|
96
|
+
},
|
|
97
|
+
async reclaimStale(maxIdleMs = 6e4) {
|
|
98
|
+
const result = await redis.xautoclaim(
|
|
99
|
+
STREAM_KEY,
|
|
100
|
+
GROUP_NAME,
|
|
101
|
+
consumer,
|
|
102
|
+
maxIdleMs,
|
|
103
|
+
"0-0",
|
|
104
|
+
"COUNT",
|
|
105
|
+
"10"
|
|
106
|
+
);
|
|
107
|
+
const entries = Array.isArray(result) ? result[1] ?? [] : [];
|
|
108
|
+
return entries.map(([entryId, fields]) => {
|
|
109
|
+
const obj = {};
|
|
110
|
+
for (let i = 0; i < fields.length; i += 2) obj[fields[i]] = fields[i + 1];
|
|
111
|
+
return {
|
|
112
|
+
id: entryId,
|
|
113
|
+
jobId: obj.jobId,
|
|
114
|
+
tenantId: obj.tenantId,
|
|
115
|
+
projectId: obj.projectId,
|
|
116
|
+
payload: JSON.parse(obj.payload ?? "{}"),
|
|
117
|
+
enqueuedAt: obj.enqueuedAt
|
|
118
|
+
};
|
|
119
|
+
});
|
|
120
|
+
},
|
|
121
|
+
async stats() {
|
|
122
|
+
const info = await redis.xinfo("GROUPS", STREAM_KEY);
|
|
123
|
+
const group = info.find((g) => {
|
|
124
|
+
const arr = Array.isArray(g) ? g : [];
|
|
125
|
+
for (let i = 0; i < arr.length; i += 2) {
|
|
126
|
+
if (arr[i] === "name" && arr[i + 1] === GROUP_NAME) return true;
|
|
127
|
+
}
|
|
128
|
+
return false;
|
|
129
|
+
});
|
|
130
|
+
if (!group) return { pending: 0, delivered: 0, groupLag: 0 };
|
|
131
|
+
const flat = Array.isArray(group) ? group : [];
|
|
132
|
+
const get = (key) => {
|
|
133
|
+
for (let i = 0; i < flat.length - 1; i += 2) {
|
|
134
|
+
if (flat[i] === key) return Number(flat[i + 1]) || 0;
|
|
135
|
+
}
|
|
136
|
+
return 0;
|
|
137
|
+
};
|
|
138
|
+
return {
|
|
139
|
+
pending: get("pel-count"),
|
|
140
|
+
delivered: get("delivered-count"),
|
|
141
|
+
groupLag: get("lag")
|
|
142
|
+
};
|
|
143
|
+
},
|
|
144
|
+
async close() {
|
|
145
|
+
await redis.quit().catch(() => {
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
function isRedisQueueEnabled() {
|
|
151
|
+
return !!(process.env.REDIS_URL && process.env.JOB_QUEUE_BACKEND === "redis");
|
|
152
|
+
}
|
|
153
|
+
async function maybeCreateRedisQueue() {
|
|
154
|
+
if (!isRedisQueueEnabled()) return null;
|
|
155
|
+
return createRedisQueue().catch((e) => {
|
|
156
|
+
console.error("[redis-queue] Failed to connect:", e.message);
|
|
157
|
+
return null;
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
export {
|
|
161
|
+
createRedisQueue,
|
|
162
|
+
isRedisQueueEnabled,
|
|
163
|
+
maybeCreateRedisQueue
|
|
164
|
+
};
|
|
165
|
+
//# sourceMappingURL=redis-queue.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../src/redis-queue.ts"],
|
|
4
|
+
"sourcesContent": ["/**\n * Redis Streams distributed job queue.\n *\n * Provides horizontal crawler scaling via Redis Streams consumer groups.\n * Multiple crawler nodes can pull from the same stream \u2014 each job is\n * delivered to exactly one consumer (at-least-once via ACK/PENDING).\n *\n * Used when REDIS_URL is set AND JOB_QUEUE_BACKEND=redis.\n * Falls back to the Postgres-backed queue when Redis is unavailable.\n *\n * Stream key: zeta:crawl:stream\n * Group name: zeta:crawl:workers\n * Consumer id: zeta:worker:<hostname>:<pid>\n */\n\nimport { createHash } from 'node:crypto';\nimport { hostname } from 'node:os';\n\nexport interface RedisQueueJob {\n id: string; // Redis stream entry ID (e.g. \"1720001234567-0\")\n jobId: string; // Postgres job row ID\n tenantId: string;\n projectId: string;\n payload: Record<string, unknown>;\n enqueuedAt: string;\n}\n\nexport interface RedisQueueClient {\n enqueue(jobId: string, tenantId: string, projectId: string, payload?: Record<string, unknown>): Promise<string>;\n dequeue(timeoutMs?: number): Promise<RedisQueueJob | null>;\n ack(streamEntryId: string): Promise<void>;\n nack(streamEntryId: string): Promise<void>;\n reclaimStale(maxIdleMs?: number): Promise<RedisQueueJob[]>;\n stats(): Promise<{ pending: number; delivered: number; groupLag: number }>;\n close(): Promise<void>;\n}\n\nconst STREAM_KEY = 'zeta:crawl:stream';\nconst GROUP_NAME = 'zeta:crawl:workers';\nconst MAX_LEN = 10_000; // approx cap on stream length\n\nfunction consumerId(): string {\n return `zeta:worker:${hostname()}:${process.pid}`;\n}\n\nlet ioredis: any;\nasync function getIoredis() {\n if (!ioredis) {\n ioredis = await import('ioredis').then(m => m.default ?? m);\n }\n return ioredis;\n}\n\n/**\n * Create a Redis Streams job queue client.\n * Throws if REDIS_URL is not set.\n */\nexport async function createRedisQueue(): Promise<RedisQueueClient> {\n const redisUrl = process.env.REDIS_URL;\n if (!redisUrl) throw new Error('REDIS_URL env var required for redis queue backend');\n\n const Redis = await getIoredis();\n const redis = new Redis(redisUrl, {\n maxRetriesPerRequest: 3,\n enableReadyCheck: true,\n lazyConnect: false,\n });\n\n // Create stream + consumer group if they don't exist\n await redis.xgroup('CREATE', STREAM_KEY, GROUP_NAME, '$', 'MKSTREAM').catch((e: any) => {\n if (!e.message?.includes('BUSYGROUP')) throw e;\n });\n\n const consumer = consumerId();\n\n return {\n async enqueue(jobId, tenantId, projectId, payload = {}): Promise<string> {\n const entryId = await redis.xadd(\n STREAM_KEY, 'MAXLEN', '~', MAX_LEN, '*',\n 'jobId', jobId,\n 'tenantId', tenantId,\n 'projectId', projectId,\n 'payload', JSON.stringify(payload),\n 'enqueuedAt', new Date().toISOString(),\n );\n return entryId as string;\n },\n\n async dequeue(timeoutMs = 5_000): Promise<RedisQueueJob | null> {\n // First drain pending (unacknowledged) messages for this consumer\n const pending: any[][] = await redis.xreadgroup(\n 'GROUP', GROUP_NAME, consumer,\n 'COUNT', '1',\n 'STREAMS', STREAM_KEY, '0',\n ) ?? [];\n\n let entries = pending?.[0]?.[1] ?? [];\n\n if (!entries.length) {\n // Block-read for new messages\n const fresh: any[][] = await redis.xreadgroup(\n 'GROUP', GROUP_NAME, consumer,\n 'COUNT', '1',\n 'BLOCK', timeoutMs,\n 'STREAMS', STREAM_KEY, '>',\n ) ?? [];\n entries = fresh?.[0]?.[1] ?? [];\n }\n\n if (!entries.length) return null;\n\n const [entryId, fields] = entries[0] as [string, string[]];\n const obj: Record<string, string> = {};\n for (let i = 0; i < fields.length; i += 2) {\n obj[fields[i]] = fields[i + 1];\n }\n\n return {\n id: entryId,\n jobId: obj.jobId,\n tenantId: obj.tenantId,\n projectId: obj.projectId,\n payload: JSON.parse(obj.payload ?? '{}'),\n enqueuedAt: obj.enqueuedAt,\n };\n },\n\n async ack(streamEntryId: string): Promise<void> {\n await redis.xack(STREAM_KEY, GROUP_NAME, streamEntryId);\n },\n\n async nack(streamEntryId: string): Promise<void> {\n // Move back to '>'-readable by deleting and re-adding (simplest approach)\n // In production, prefer a dead-letter stream instead.\n await redis.xack(STREAM_KEY, GROUP_NAME, streamEntryId);\n },\n\n async reclaimStale(maxIdleMs = 60_000): Promise<RedisQueueJob[]> {\n const result: any = await redis.xautoclaim(\n STREAM_KEY, GROUP_NAME, consumer,\n maxIdleMs, '0-0', 'COUNT', '10',\n );\n const entries: [string, string[]][] = Array.isArray(result) ? (result[1] ?? []) : [];\n return entries.map(([entryId, fields]) => {\n const obj: Record<string, string> = {};\n for (let i = 0; i < fields.length; i += 2) obj[fields[i]] = fields[i + 1];\n return {\n id: entryId,\n jobId: obj.jobId,\n tenantId: obj.tenantId,\n projectId: obj.projectId,\n payload: JSON.parse(obj.payload ?? '{}'),\n enqueuedAt: obj.enqueuedAt,\n };\n });\n },\n\n async stats(): Promise<{ pending: number; delivered: number; groupLag: number }> {\n const info: any[] = await redis.xinfo('GROUPS', STREAM_KEY);\n // xinfo groups returns array of arrays: [name, ..., pel-count, ..., lag, ...]\n const group = info.find((g: any) => {\n const arr = Array.isArray(g) ? g : [];\n for (let i = 0; i < arr.length; i += 2) {\n if (arr[i] === 'name' && arr[i + 1] === GROUP_NAME) return true;\n }\n return false;\n });\n\n if (!group) return { pending: 0, delivered: 0, groupLag: 0 };\n\n const flat = Array.isArray(group) ? group : [];\n const get = (key: string) => {\n for (let i = 0; i < flat.length - 1; i += 2) {\n if (flat[i] === key) return Number(flat[i + 1]) || 0;\n }\n return 0;\n };\n\n return {\n pending: get('pel-count'),\n delivered: get('delivered-count'),\n groupLag: get('lag'),\n };\n },\n\n async close(): Promise<void> {\n await redis.quit().catch(() => {});\n },\n };\n}\n\n/**\n * Check if Redis queue backend is configured.\n */\nexport function isRedisQueueEnabled(): boolean {\n return !!(process.env.REDIS_URL && process.env.JOB_QUEUE_BACKEND === 'redis');\n}\n\n/**\n * Convenience: create queue only if enabled, else return null.\n */\nexport async function maybeCreateRedisQueue(): Promise<RedisQueueClient | null> {\n if (!isRedisQueueEnabled()) return null;\n return createRedisQueue().catch((e) => {\n console.error('[redis-queue] Failed to connect:', e.message);\n return null;\n });\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export interface ResponseCacheEntry {
|
|
2
|
+
url: string;
|
|
3
|
+
etag?: string;
|
|
4
|
+
lastModified?: string;
|
|
5
|
+
cacheControl?: string;
|
|
6
|
+
body: string;
|
|
7
|
+
contentType: string;
|
|
8
|
+
statusCode: number;
|
|
9
|
+
cachedAt: number;
|
|
10
|
+
hitCount: number;
|
|
11
|
+
}
|
|
12
|
+
export declare class ResponseCache {
|
|
13
|
+
private readonly dir;
|
|
14
|
+
private totalHits;
|
|
15
|
+
private totalRequests;
|
|
16
|
+
constructor(cacheDir: string);
|
|
17
|
+
private key;
|
|
18
|
+
private path;
|
|
19
|
+
get(url: string): ResponseCacheEntry | null;
|
|
20
|
+
private read;
|
|
21
|
+
isFresh(url: string): boolean;
|
|
22
|
+
staleIfError(url: string): boolean;
|
|
23
|
+
set(url: string, entry: Omit<ResponseCacheEntry, 'cachedAt' | 'hitCount'>): void;
|
|
24
|
+
getValidationHeaders(url: string): {
|
|
25
|
+
'If-None-Match'?: string;
|
|
26
|
+
'If-Modified-Since'?: string;
|
|
27
|
+
};
|
|
28
|
+
markHit(url: string): void;
|
|
29
|
+
invalidate(url: string): void;
|
|
30
|
+
clear(): void;
|
|
31
|
+
stats(): {
|
|
32
|
+
entries: number;
|
|
33
|
+
totalSizeBytes: number;
|
|
34
|
+
hitRate: number;
|
|
35
|
+
totalHits: number;
|
|
36
|
+
};
|
|
37
|
+
prune(maxAgeMs: number): number;
|
|
38
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
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
|
+
class ResponseCache {
|
|
5
|
+
dir;
|
|
6
|
+
totalHits = 0;
|
|
7
|
+
totalRequests = 0;
|
|
8
|
+
constructor(cacheDir) {
|
|
9
|
+
this.dir = cacheDir;
|
|
10
|
+
if (!existsSync(cacheDir)) mkdirSync(cacheDir, { recursive: true });
|
|
11
|
+
}
|
|
12
|
+
key(url) {
|
|
13
|
+
return createHash("sha256").update(url).digest("hex").slice(0, 16);
|
|
14
|
+
}
|
|
15
|
+
path(url) {
|
|
16
|
+
return join(this.dir, `${this.key(url)}.json`);
|
|
17
|
+
}
|
|
18
|
+
get(url) {
|
|
19
|
+
this.totalRequests++;
|
|
20
|
+
try {
|
|
21
|
+
const p = this.path(url);
|
|
22
|
+
if (!existsSync(p)) return null;
|
|
23
|
+
const entry = JSON.parse(readFileSync(p, "utf-8"));
|
|
24
|
+
this.totalHits++;
|
|
25
|
+
return entry;
|
|
26
|
+
} catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
read(url) {
|
|
31
|
+
try {
|
|
32
|
+
const p = this.path(url);
|
|
33
|
+
if (!existsSync(p)) return null;
|
|
34
|
+
return JSON.parse(readFileSync(p, "utf-8"));
|
|
35
|
+
} catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
isFresh(url) {
|
|
40
|
+
const entry = this.read(url);
|
|
41
|
+
if (!entry?.cacheControl) return false;
|
|
42
|
+
const m = /max-age=(\d+)/i.exec(entry.cacheControl);
|
|
43
|
+
if (!m) return false;
|
|
44
|
+
const maxAgeMs = parseInt(m[1], 10) * 1e3;
|
|
45
|
+
return entry.cachedAt + maxAgeMs > Date.now();
|
|
46
|
+
}
|
|
47
|
+
staleIfError(url) {
|
|
48
|
+
const entry = this.read(url);
|
|
49
|
+
if (!entry?.cacheControl) return false;
|
|
50
|
+
const m = /stale-if-error=(\d+)/i.exec(entry.cacheControl);
|
|
51
|
+
if (!m) return false;
|
|
52
|
+
const staleIfErrorMs = parseInt(m[1], 10) * 1e3;
|
|
53
|
+
return entry.cachedAt + staleIfErrorMs >= Date.now();
|
|
54
|
+
}
|
|
55
|
+
set(url, entry) {
|
|
56
|
+
try {
|
|
57
|
+
const full = { ...entry, cachedAt: Date.now(), hitCount: 0 };
|
|
58
|
+
writeFileSync(this.path(url), JSON.stringify(full), "utf-8");
|
|
59
|
+
} catch {
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
getValidationHeaders(url) {
|
|
63
|
+
const entry = this.get(url);
|
|
64
|
+
if (!entry) return {};
|
|
65
|
+
const headers = {};
|
|
66
|
+
if (entry.etag) headers["If-None-Match"] = entry.etag;
|
|
67
|
+
if (entry.lastModified) headers["If-Modified-Since"] = entry.lastModified;
|
|
68
|
+
return headers;
|
|
69
|
+
}
|
|
70
|
+
markHit(url) {
|
|
71
|
+
try {
|
|
72
|
+
const p = this.path(url);
|
|
73
|
+
if (!existsSync(p)) return;
|
|
74
|
+
const entry = JSON.parse(readFileSync(p, "utf-8"));
|
|
75
|
+
entry.hitCount++;
|
|
76
|
+
writeFileSync(p, JSON.stringify(entry), "utf-8");
|
|
77
|
+
} catch {
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
invalidate(url) {
|
|
81
|
+
try {
|
|
82
|
+
const p = this.path(url);
|
|
83
|
+
if (existsSync(p)) unlinkSync(p);
|
|
84
|
+
} catch {
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
clear() {
|
|
88
|
+
try {
|
|
89
|
+
for (const f of readdirSync(this.dir)) {
|
|
90
|
+
if (f.endsWith(".json")) unlinkSync(join(this.dir, f));
|
|
91
|
+
}
|
|
92
|
+
} catch {
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
stats() {
|
|
96
|
+
let entries = 0;
|
|
97
|
+
let totalSizeBytes = 0;
|
|
98
|
+
try {
|
|
99
|
+
for (const f of readdirSync(this.dir)) {
|
|
100
|
+
if (!f.endsWith(".json")) continue;
|
|
101
|
+
entries++;
|
|
102
|
+
try {
|
|
103
|
+
totalSizeBytes += statSync(join(this.dir, f)).size;
|
|
104
|
+
} catch {
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
} catch {
|
|
108
|
+
}
|
|
109
|
+
return {
|
|
110
|
+
entries,
|
|
111
|
+
totalSizeBytes,
|
|
112
|
+
hitRate: this.totalRequests > 0 ? this.totalHits / this.totalRequests : 0,
|
|
113
|
+
totalHits: this.totalHits
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
prune(maxAgeMs) {
|
|
117
|
+
let deleted = 0;
|
|
118
|
+
const cutoff = Date.now() - maxAgeMs;
|
|
119
|
+
try {
|
|
120
|
+
for (const f of readdirSync(this.dir)) {
|
|
121
|
+
if (!f.endsWith(".json")) continue;
|
|
122
|
+
try {
|
|
123
|
+
const p = join(this.dir, f);
|
|
124
|
+
const entry = JSON.parse(readFileSync(p, "utf-8"));
|
|
125
|
+
if (entry.cachedAt < cutoff) {
|
|
126
|
+
unlinkSync(p);
|
|
127
|
+
deleted++;
|
|
128
|
+
}
|
|
129
|
+
} catch {
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
} catch {
|
|
133
|
+
}
|
|
134
|
+
return deleted;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
export {
|
|
138
|
+
ResponseCache
|
|
139
|
+
};
|
|
140
|
+
//# sourceMappingURL=response-cache.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../src/response-cache.ts"],
|
|
4
|
+
"sourcesContent": ["import { createHash } from 'node:crypto';\nimport { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, statSync, unlinkSync } from 'node:fs';\nimport { join } from 'node:path';\n\nexport interface ResponseCacheEntry {\n url: string;\n etag?: string;\n lastModified?: string;\n cacheControl?: string;\n body: string;\n contentType: string;\n statusCode: number;\n cachedAt: number;\n hitCount: number;\n}\n\nexport class ResponseCache {\n private readonly dir: string;\n private totalHits = 0;\n private totalRequests = 0;\n\n constructor(cacheDir: string) {\n this.dir = cacheDir;\n if (!existsSync(cacheDir)) mkdirSync(cacheDir, { recursive: true });\n }\n\n private key(url: string): string {\n return createHash('sha256').update(url).digest('hex').slice(0, 16);\n }\n\n private path(url: string): string {\n return join(this.dir, `${this.key(url)}.json`);\n }\n\n get(url: string): ResponseCacheEntry | null {\n this.totalRequests++;\n try {\n const p = this.path(url);\n if (!existsSync(p)) return null;\n const entry: ResponseCacheEntry = JSON.parse(readFileSync(p, 'utf-8'));\n this.totalHits++;\n return entry;\n } catch {\n return null;\n }\n }\n\n private read(url: string): ResponseCacheEntry | null {\n try {\n const p = this.path(url);\n if (!existsSync(p)) return null;\n return JSON.parse(readFileSync(p, 'utf-8')) as ResponseCacheEntry;\n } catch {\n return null;\n }\n }\n\n isFresh(url: string): boolean {\n const entry = this.read(url);\n if (!entry?.cacheControl) return false;\n const m = /max-age=(\\d+)/i.exec(entry.cacheControl);\n if (!m) return false;\n const maxAgeMs = parseInt(m[1], 10) * 1000;\n return entry.cachedAt + maxAgeMs > Date.now();\n }\n\n staleIfError(url: string): boolean {\n const entry = this.read(url);\n if (!entry?.cacheControl) return false;\n const m = /stale-if-error=(\\d+)/i.exec(entry.cacheControl);\n if (!m) return false;\n const staleIfErrorMs = parseInt(m[1], 10) * 1000;\n return entry.cachedAt + staleIfErrorMs >= Date.now();\n }\n\n set(url: string, entry: Omit<ResponseCacheEntry, 'cachedAt' | 'hitCount'>): void {\n try {\n const full: ResponseCacheEntry = { ...entry, cachedAt: Date.now(), hitCount: 0 };\n writeFileSync(this.path(url), JSON.stringify(full), 'utf-8');\n } catch { /* non-fatal */ }\n }\n\n getValidationHeaders(url: string): { 'If-None-Match'?: string; 'If-Modified-Since'?: string } {\n const entry = this.get(url);\n if (!entry) return {};\n const headers: { 'If-None-Match'?: string; 'If-Modified-Since'?: string } = {};\n if (entry.etag) headers['If-None-Match'] = entry.etag;\n if (entry.lastModified) headers['If-Modified-Since'] = entry.lastModified;\n return headers;\n }\n\n markHit(url: string): void {\n try {\n const p = this.path(url);\n if (!existsSync(p)) return;\n const entry: ResponseCacheEntry = JSON.parse(readFileSync(p, 'utf-8'));\n entry.hitCount++;\n writeFileSync(p, JSON.stringify(entry), 'utf-8');\n } catch { /* non-fatal */ }\n }\n\n invalidate(url: string): void {\n try {\n const p = this.path(url);\n if (existsSync(p)) unlinkSync(p);\n } catch { /* non-fatal */ }\n }\n\n clear(): void {\n try {\n for (const f of readdirSync(this.dir)) {\n if (f.endsWith('.json')) unlinkSync(join(this.dir, f));\n }\n } catch { /* non-fatal */ }\n }\n\n stats(): { entries: number; totalSizeBytes: number; hitRate: number; totalHits: number } {\n let entries = 0;\n let totalSizeBytes = 0;\n try {\n for (const f of readdirSync(this.dir)) {\n if (!f.endsWith('.json')) continue;\n entries++;\n try { totalSizeBytes += statSync(join(this.dir, f)).size; } catch { /* skip */ }\n }\n } catch { /* non-fatal */ }\n return {\n entries,\n totalSizeBytes,\n hitRate: this.totalRequests > 0 ? this.totalHits / this.totalRequests : 0,\n totalHits: this.totalHits,\n };\n }\n\n prune(maxAgeMs: number): number {\n let deleted = 0;\n const cutoff = Date.now() - maxAgeMs;\n try {\n for (const f of readdirSync(this.dir)) {\n if (!f.endsWith('.json')) continue;\n try {\n const p = join(this.dir, f);\n const entry: ResponseCacheEntry = JSON.parse(readFileSync(p, 'utf-8'));\n if (entry.cachedAt < cutoff) { unlinkSync(p); deleted++; }\n } catch { /* skip */ }\n }\n } catch { /* non-fatal */ }\n return deleted;\n }\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
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
|
+
import type { Page } from 'playwright';
|
|
10
|
+
export interface ViewportCapture {
|
|
11
|
+
width: number;
|
|
12
|
+
height: number;
|
|
13
|
+
label: 'mobile' | 'tablet' | 'desktop' | 'wide';
|
|
14
|
+
screenshotBuffer?: Buffer;
|
|
15
|
+
visibleTextLength: number;
|
|
16
|
+
hasHamburgerMenu: boolean;
|
|
17
|
+
hiddenElements: number;
|
|
18
|
+
scrollHeight: number;
|
|
19
|
+
}
|
|
20
|
+
export interface ResponsiveReport {
|
|
21
|
+
url: string;
|
|
22
|
+
captures: ViewportCapture[];
|
|
23
|
+
isResponsive: boolean;
|
|
24
|
+
hasMobileMenu: boolean;
|
|
25
|
+
issues: string[];
|
|
26
|
+
}
|
|
27
|
+
export declare function detectResponsive(page: Page, url: string, opts?: {
|
|
28
|
+
captureScreenshots?: boolean;
|
|
29
|
+
}): Promise<ResponsiveReport>;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
const BREAKPOINTS = [
|
|
2
|
+
{ width: 375, height: 812, label: "mobile" },
|
|
3
|
+
{ width: 768, height: 1024, label: "tablet" },
|
|
4
|
+
{ width: 1280, height: 800, label: "desktop" }
|
|
5
|
+
];
|
|
6
|
+
async function detectResponsive(page, url, opts = {}) {
|
|
7
|
+
const captures = [];
|
|
8
|
+
const issues = [];
|
|
9
|
+
for (const bp of BREAKPOINTS) {
|
|
10
|
+
try {
|
|
11
|
+
await page.setViewportSize({ width: bp.width, height: bp.height });
|
|
12
|
+
await page.waitForTimeout(500);
|
|
13
|
+
const data = await page.evaluate(() => ({
|
|
14
|
+
visibleTextLength: (document.body.innerText ?? "").length,
|
|
15
|
+
hasHamburgerMenu: !!document.querySelector('[class*="hamburger"],[class*="menu-toggle"],[class*="nav-toggle"],[aria-label*="menu"],[aria-label*="Menu"]'),
|
|
16
|
+
hiddenElements: Array.from(document.querySelectorAll("*")).filter(
|
|
17
|
+
(el) => window.getComputedStyle(el).display === "none"
|
|
18
|
+
).length,
|
|
19
|
+
scrollHeight: document.body.scrollHeight
|
|
20
|
+
})).catch(() => ({ visibleTextLength: 0, hasHamburgerMenu: false, hiddenElements: 0, scrollHeight: 0 }));
|
|
21
|
+
let screenshotBuffer;
|
|
22
|
+
if (opts.captureScreenshots) {
|
|
23
|
+
screenshotBuffer = await page.screenshot({ type: "jpeg", quality: 60 }).catch(() => void 0);
|
|
24
|
+
}
|
|
25
|
+
captures.push({ ...bp, ...data, screenshotBuffer });
|
|
26
|
+
} catch {
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
await page.setViewportSize({ width: 1280, height: 800 }).catch(() => {
|
|
30
|
+
});
|
|
31
|
+
const mobile = captures.find((c) => c.label === "mobile");
|
|
32
|
+
const desktop = captures.find((c) => c.label === "desktop");
|
|
33
|
+
const isResponsive = !!(mobile && desktop && (mobile.hiddenElements !== desktop.hiddenElements || mobile.scrollHeight !== desktop.scrollHeight || mobile.hasHamburgerMenu));
|
|
34
|
+
const hasMobileMenu = captures.some((c) => c.label === "mobile" && c.hasHamburgerMenu);
|
|
35
|
+
if (mobile && desktop && mobile.visibleTextLength > desktop.visibleTextLength * 1.3) {
|
|
36
|
+
issues.push("Mobile shows significantly more text than desktop \u2014 possible responsive issue");
|
|
37
|
+
}
|
|
38
|
+
if (!isResponsive && captures.length > 1) {
|
|
39
|
+
issues.push("Layout appears identical across breakpoints \u2014 may not be responsive");
|
|
40
|
+
}
|
|
41
|
+
return { url, captures, isResponsive, hasMobileMenu, issues };
|
|
42
|
+
}
|
|
43
|
+
export {
|
|
44
|
+
detectResponsive
|
|
45
|
+
};
|
|
46
|
+
//# sourceMappingURL=responsive-detector.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../src/responsive-detector.ts"],
|
|
4
|
+
"sourcesContent": ["/**\n * Responsive design detector \u2014 captures page at multiple breakpoints\n * and detects layout differences between desktop and mobile viewports.\n *\n * Breakpoints: mobile(375), tablet(768), desktop(1280), wide(1920)\n * Detects: viewport-specific content visibility, menu collapse state,\n * font size changes, hidden/shown elements.\n */\n\nimport type { Page } from 'playwright';\n\nexport interface ViewportCapture {\n width: number;\n height: number;\n label: 'mobile' | 'tablet' | 'desktop' | 'wide';\n screenshotBuffer?: Buffer;\n visibleTextLength: number;\n hasHamburgerMenu: boolean;\n hiddenElements: number; // display:none element count\n scrollHeight: number;\n}\n\nexport interface ResponsiveReport {\n url: string;\n captures: ViewportCapture[];\n isResponsive: boolean; // true if layout changes between viewports\n hasMobileMenu: boolean;\n issues: string[];\n}\n\nconst BREAKPOINTS: Array<{ width: number; height: number; label: ViewportCapture['label'] }> = [\n { width: 375, height: 812, label: 'mobile' },\n { width: 768, height: 1024, label: 'tablet' },\n { width: 1280, height: 800, label: 'desktop' },\n];\n\nexport async function detectResponsive(\n page: Page,\n url: string,\n opts: { captureScreenshots?: boolean } = {},\n): Promise<ResponsiveReport> {\n const captures: ViewportCapture[] = [];\n const issues: string[] = [];\n\n for (const bp of BREAKPOINTS) {\n try {\n await page.setViewportSize({ width: bp.width, height: bp.height });\n await page.waitForTimeout(500); // allow CSS reflow\n\n const data = await page.evaluate(() => ({\n visibleTextLength: (document.body.innerText ?? '').length,\n hasHamburgerMenu: !!(\n document.querySelector('[class*=\"hamburger\"],[class*=\"menu-toggle\"],[class*=\"nav-toggle\"],[aria-label*=\"menu\"],[aria-label*=\"Menu\"]')\n ),\n hiddenElements: Array.from(document.querySelectorAll('*')).filter(\n (el: any) => window.getComputedStyle(el).display === 'none'\n ).length,\n scrollHeight: document.body.scrollHeight,\n })).catch(() => ({ visibleTextLength: 0, hasHamburgerMenu: false, hiddenElements: 0, scrollHeight: 0 }));\n\n let screenshotBuffer: Buffer | undefined;\n if (opts.captureScreenshots) {\n screenshotBuffer = await page.screenshot({ type: 'jpeg', quality: 60 }).catch(() => undefined);\n }\n\n captures.push({ ...bp, ...data, screenshotBuffer });\n } catch { /* viewport failed \u2014 skip */ }\n }\n\n // Restore to desktop\n await page.setViewportSize({ width: 1280, height: 800 }).catch(() => {});\n\n const mobile = captures.find(c => c.label === 'mobile');\n const desktop = captures.find(c => c.label === 'desktop');\n\n const isResponsive = !!(mobile && desktop && (\n mobile.hiddenElements !== desktop.hiddenElements ||\n mobile.scrollHeight !== desktop.scrollHeight ||\n mobile.hasHamburgerMenu\n ));\n\n const hasMobileMenu = captures.some(c => c.label === 'mobile' && c.hasHamburgerMenu);\n\n if (mobile && desktop && mobile.visibleTextLength > desktop.visibleTextLength * 1.3) {\n issues.push('Mobile shows significantly more text than desktop \u2014 possible responsive issue');\n }\n if (!isResponsive && captures.length > 1) {\n issues.push('Layout appears identical across breakpoints \u2014 may not be responsive');\n }\n\n return { url, captures, isResponsive, hasMobileMenu, issues };\n}\n"],
|
|
5
|
+
"mappings": "AA8BA,MAAM,cAAyF;AAAA,EAC7F,EAAE,OAAO,KAAK,QAAQ,KAAK,OAAO,SAAS;AAAA,EAC3C,EAAE,OAAO,KAAK,QAAQ,MAAM,OAAO,SAAS;AAAA,EAC5C,EAAE,OAAO,MAAM,QAAQ,KAAK,OAAO,UAAU;AAC/C;AAEA,eAAsB,iBACpB,MACA,KACA,OAAyC,CAAC,GACf;AAC3B,QAAM,WAA8B,CAAC;AACrC,QAAM,SAAmB,CAAC;AAE1B,aAAW,MAAM,aAAa;AAC5B,QAAI;AACF,YAAM,KAAK,gBAAgB,EAAE,OAAO,GAAG,OAAO,QAAQ,GAAG,OAAO,CAAC;AACjE,YAAM,KAAK,eAAe,GAAG;AAE7B,YAAM,OAAO,MAAM,KAAK,SAAS,OAAO;AAAA,QACtC,oBAAoB,SAAS,KAAK,aAAa,IAAI;AAAA,QACnD,kBAAkB,CAAC,CACjB,SAAS,cAAc,6GAA6G;AAAA,QAEtI,gBAAgB,MAAM,KAAK,SAAS,iBAAiB,GAAG,CAAC,EAAE;AAAA,UACzD,CAAC,OAAY,OAAO,iBAAiB,EAAE,EAAE,YAAY;AAAA,QACvD,EAAE;AAAA,QACF,cAAc,SAAS,KAAK;AAAA,MAC9B,EAAE,EAAE,MAAM,OAAO,EAAE,mBAAmB,GAAG,kBAAkB,OAAO,gBAAgB,GAAG,cAAc,EAAE,EAAE;AAEvG,UAAI;AACJ,UAAI,KAAK,oBAAoB;AAC3B,2BAAmB,MAAM,KAAK,WAAW,EAAE,MAAM,QAAQ,SAAS,GAAG,CAAC,EAAE,MAAM,MAAM,MAAS;AAAA,MAC/F;AAEA,eAAS,KAAK,EAAE,GAAG,IAAI,GAAG,MAAM,iBAAiB,CAAC;AAAA,IACpD,QAAQ;AAAA,IAA+B;AAAA,EACzC;AAGA,QAAM,KAAK,gBAAgB,EAAE,OAAO,MAAM,QAAQ,IAAI,CAAC,EAAE,MAAM,MAAM;AAAA,EAAC,CAAC;AAEvE,QAAM,SAAS,SAAS,KAAK,OAAK,EAAE,UAAU,QAAQ;AACtD,QAAM,UAAU,SAAS,KAAK,OAAK,EAAE,UAAU,SAAS;AAExD,QAAM,eAAe,CAAC,EAAE,UAAU,YAChC,OAAO,mBAAmB,QAAQ,kBAClC,OAAO,iBAAiB,QAAQ,gBAChC,OAAO;AAGT,QAAM,gBAAgB,SAAS,KAAK,OAAK,EAAE,UAAU,YAAY,EAAE,gBAAgB;AAEnF,MAAI,UAAU,WAAW,OAAO,oBAAoB,QAAQ,oBAAoB,KAAK;AACnF,WAAO,KAAK,oFAA+E;AAAA,EAC7F;AACA,MAAI,CAAC,gBAAgB,SAAS,SAAS,GAAG;AACxC,WAAO,KAAK,0EAAqE;AAAA,EACnF;AAEA,SAAO,EAAE,KAAK,UAAU,cAAc,eAAe,OAAO;AAC9D;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
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
|
+
export interface RetryPolicyOpts {
|
|
7
|
+
maxRetries?: number;
|
|
8
|
+
baseDelayMs?: number;
|
|
9
|
+
maxDelayMs?: number;
|
|
10
|
+
jitterMs?: number;
|
|
11
|
+
retryOn?: number[];
|
|
12
|
+
}
|
|
13
|
+
export interface RetryResult<T> {
|
|
14
|
+
value: T;
|
|
15
|
+
attempts: number;
|
|
16
|
+
totalDelayMs: number;
|
|
17
|
+
}
|
|
18
|
+
export declare function withRetry<T>(fn: (attempt: number) => Promise<T>, opts?: RetryPolicyOpts): Promise<RetryResult<T>>;
|
|
19
|
+
/** Wraps fetch() with retry policy */
|
|
20
|
+
export declare function fetchWithRetry(url: string, init?: RequestInit, retryOpts?: RetryPolicyOpts): Promise<Response>;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
const DEFAULT_RETRY_STATUSES = [429, 500, 502, 503, 504];
|
|
2
|
+
function shouldRetry(error, retryOn) {
|
|
3
|
+
if (error?.code && ["ECONNRESET", "ECONNREFUSED", "ETIMEDOUT", "ENOTFOUND", "EPIPE"].includes(error.code)) return true;
|
|
4
|
+
if (error?.message && /timeout|net::ERR_/i.test(error.message)) return true;
|
|
5
|
+
if (typeof error?.status === "number") return retryOn.includes(error.status);
|
|
6
|
+
return false;
|
|
7
|
+
}
|
|
8
|
+
function delay(ms) {
|
|
9
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
10
|
+
}
|
|
11
|
+
async function withRetry(fn, opts = {}) {
|
|
12
|
+
const {
|
|
13
|
+
maxRetries = 3,
|
|
14
|
+
baseDelayMs = 1e3,
|
|
15
|
+
maxDelayMs = 3e4,
|
|
16
|
+
jitterMs = 500,
|
|
17
|
+
retryOn = DEFAULT_RETRY_STATUSES
|
|
18
|
+
} = opts;
|
|
19
|
+
let lastError;
|
|
20
|
+
let totalDelayMs = 0;
|
|
21
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
22
|
+
try {
|
|
23
|
+
const value = await fn(attempt);
|
|
24
|
+
return { value, attempts: attempt + 1, totalDelayMs };
|
|
25
|
+
} catch (err) {
|
|
26
|
+
lastError = err;
|
|
27
|
+
if (attempt === maxRetries) break;
|
|
28
|
+
if (!shouldRetry(err, retryOn)) throw err;
|
|
29
|
+
const backoff = Math.min(maxDelayMs, baseDelayMs * Math.pow(2, attempt));
|
|
30
|
+
const jitter = Math.random() * jitterMs;
|
|
31
|
+
const waitMs = Math.round(backoff + jitter);
|
|
32
|
+
totalDelayMs += waitMs;
|
|
33
|
+
await delay(waitMs);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
throw lastError;
|
|
37
|
+
}
|
|
38
|
+
async function fetchWithRetry(url, init = {}, retryOpts = {}) {
|
|
39
|
+
const result = await withRetry(async () => {
|
|
40
|
+
const res = await fetch(url, init);
|
|
41
|
+
if (retryOpts.retryOn?.includes(res.status) ?? DEFAULT_RETRY_STATUSES.includes(res.status)) {
|
|
42
|
+
const err = new Error(`HTTP ${res.status}`);
|
|
43
|
+
err.status = res.status;
|
|
44
|
+
throw err;
|
|
45
|
+
}
|
|
46
|
+
return res;
|
|
47
|
+
}, retryOpts);
|
|
48
|
+
return result.value;
|
|
49
|
+
}
|
|
50
|
+
export {
|
|
51
|
+
fetchWithRetry,
|
|
52
|
+
withRetry
|
|
53
|
+
};
|
|
54
|
+
//# sourceMappingURL=retry-policy.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../src/retry-policy.ts"],
|
|
4
|
+
"sourcesContent": ["/**\n * Retry policy \u2014 configurable exponential backoff with jitter.\n * Covers: 5xx server errors, network timeouts, ECONNREFUSED, ECONNRESET.\n * NOT retried: 4xx client errors (except 429 \u2014 handled separately), redirects.\n */\n\nexport interface RetryPolicyOpts {\n maxRetries?: number; // default 3\n baseDelayMs?: number; // default 1000\n maxDelayMs?: number; // default 30000\n jitterMs?: number; // default 500\n retryOn?: number[]; // HTTP status codes to retry (default: [429, 500, 502, 503, 504])\n}\n\nexport interface RetryResult<T> {\n value: T;\n attempts: number;\n totalDelayMs: number;\n}\n\nconst DEFAULT_RETRY_STATUSES = [429, 500, 502, 503, 504];\n\nfunction shouldRetry(error: any, retryOn: number[]): boolean {\n // Network-level errors\n if (error?.code && ['ECONNRESET', 'ECONNREFUSED', 'ETIMEDOUT', 'ENOTFOUND', 'EPIPE'].includes(error.code)) return true;\n // Playwright navigation timeout\n if (error?.message && /timeout|net::ERR_/i.test(error.message)) return true;\n // HTTP status errors\n if (typeof error?.status === 'number') return retryOn.includes(error.status);\n return false;\n}\n\nfunction delay(ms: number): Promise<void> {\n return new Promise(r => setTimeout(r, ms));\n}\n\nexport async function withRetry<T>(\n fn: (attempt: number) => Promise<T>,\n opts: RetryPolicyOpts = {},\n): Promise<RetryResult<T>> {\n const {\n maxRetries = 3,\n baseDelayMs = 1_000,\n maxDelayMs = 30_000,\n jitterMs = 500,\n retryOn = DEFAULT_RETRY_STATUSES,\n } = opts;\n\n let lastError: any;\n let totalDelayMs = 0;\n\n for (let attempt = 0; attempt <= maxRetries; attempt++) {\n try {\n const value = await fn(attempt);\n return { value, attempts: attempt + 1, totalDelayMs };\n } catch (err) {\n lastError = err;\n if (attempt === maxRetries) break;\n if (!shouldRetry(err, retryOn)) throw err; // non-retryable \u2014 fail fast\n\n const backoff = Math.min(maxDelayMs, baseDelayMs * Math.pow(2, attempt));\n const jitter = Math.random() * jitterMs;\n const waitMs = Math.round(backoff + jitter);\n totalDelayMs += waitMs;\n await delay(waitMs);\n }\n }\n throw lastError;\n}\n\n/** Wraps fetch() with retry policy */\nexport async function fetchWithRetry(\n url: string,\n init: RequestInit = {},\n retryOpts: RetryPolicyOpts = {},\n): Promise<Response> {\n const result = await withRetry(async () => {\n const res = await fetch(url, init);\n if (retryOpts.retryOn?.includes(res.status) ?? DEFAULT_RETRY_STATUSES.includes(res.status)) {\n const err: any = new Error(`HTTP ${res.status}`);\n err.status = res.status;\n throw err;\n }\n return res;\n }, retryOpts);\n return result.value;\n}\n"],
|
|
5
|
+
"mappings": "AAoBA,MAAM,yBAAyB,CAAC,KAAK,KAAK,KAAK,KAAK,GAAG;AAEvD,SAAS,YAAY,OAAY,SAA4B;AAE3D,MAAI,OAAO,QAAQ,CAAC,cAAc,gBAAgB,aAAa,aAAa,OAAO,EAAE,SAAS,MAAM,IAAI,EAAG,QAAO;AAElH,MAAI,OAAO,WAAW,qBAAqB,KAAK,MAAM,OAAO,EAAG,QAAO;AAEvE,MAAI,OAAO,OAAO,WAAW,SAAU,QAAO,QAAQ,SAAS,MAAM,MAAM;AAC3E,SAAO;AACT;AAEA,SAAS,MAAM,IAA2B;AACxC,SAAO,IAAI,QAAQ,OAAK,WAAW,GAAG,EAAE,CAAC;AAC3C;AAEA,eAAsB,UACpB,IACA,OAAwB,CAAC,GACA;AACzB,QAAM;AAAA,IACJ,aAAa;AAAA,IACb,cAAc;AAAA,IACd,aAAa;AAAA,IACb,WAAW;AAAA,IACX,UAAU;AAAA,EACZ,IAAI;AAEJ,MAAI;AACJ,MAAI,eAAe;AAEnB,WAAS,UAAU,GAAG,WAAW,YAAY,WAAW;AACtD,QAAI;AACF,YAAM,QAAQ,MAAM,GAAG,OAAO;AAC9B,aAAO,EAAE,OAAO,UAAU,UAAU,GAAG,aAAa;AAAA,IACtD,SAAS,KAAK;AACZ,kBAAY;AACZ,UAAI,YAAY,WAAY;AAC5B,UAAI,CAAC,YAAY,KAAK,OAAO,EAAG,OAAM;AAEtC,YAAM,UAAU,KAAK,IAAI,YAAY,cAAc,KAAK,IAAI,GAAG,OAAO,CAAC;AACvE,YAAM,SAAS,KAAK,OAAO,IAAI;AAC/B,YAAM,SAAS,KAAK,MAAM,UAAU,MAAM;AAC1C,sBAAgB;AAChB,YAAM,MAAM,MAAM;AAAA,IACpB;AAAA,EACF;AACA,QAAM;AACR;AAGA,eAAsB,eACpB,KACA,OAAoB,CAAC,GACrB,YAA6B,CAAC,GACX;AACnB,QAAM,SAAS,MAAM,UAAU,YAAY;AACzC,UAAM,MAAM,MAAM,MAAM,KAAK,IAAI;AACjC,QAAI,UAAU,SAAS,SAAS,IAAI,MAAM,KAAK,uBAAuB,SAAS,IAAI,MAAM,GAAG;AAC1F,YAAM,MAAW,IAAI,MAAM,QAAQ,IAAI,MAAM,EAAE;AAC/C,UAAI,SAAS,IAAI;AACjB,YAAM;AAAA,IACR;AACA,WAAO;AAAA,EACT,GAAG,SAAS;AACZ,SAAO,OAAO;AAChB;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
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
|
+
export declare class RobotsCache {
|
|
7
|
+
private readonly cache;
|
|
8
|
+
isAllowed(url: string, userAgent?: string): Promise<boolean>;
|
|
9
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
function parseRobots(text) {
|
|
2
|
+
const rules = [];
|
|
3
|
+
let current = null;
|
|
4
|
+
for (const rawLine of text.split("\n")) {
|
|
5
|
+
const line = rawLine.split("#")[0].trim();
|
|
6
|
+
if (!line) {
|
|
7
|
+
current = null;
|
|
8
|
+
continue;
|
|
9
|
+
}
|
|
10
|
+
const colonIdx = line.indexOf(":");
|
|
11
|
+
if (colonIdx === -1) continue;
|
|
12
|
+
const key = line.slice(0, colonIdx).trim().toLowerCase();
|
|
13
|
+
const value = line.slice(colonIdx + 1).trim();
|
|
14
|
+
if (key === "user-agent") {
|
|
15
|
+
if (!current || current.disallow.length > 0 || current.allow.length > 0) {
|
|
16
|
+
current = { userAgent: value.toLowerCase(), disallow: [], allow: [] };
|
|
17
|
+
rules.push(current);
|
|
18
|
+
} else {
|
|
19
|
+
current.userAgent = value.toLowerCase();
|
|
20
|
+
}
|
|
21
|
+
} else if (key === "disallow" && current && value) {
|
|
22
|
+
current.disallow.push(value);
|
|
23
|
+
} else if (key === "allow" && current && value) {
|
|
24
|
+
current.allow.push(value);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return rules;
|
|
28
|
+
}
|
|
29
|
+
function isAllowedByRules(rules, userAgent, pathname) {
|
|
30
|
+
const ua = userAgent.toLowerCase();
|
|
31
|
+
const exactRules = rules.filter((r) => r.userAgent === ua);
|
|
32
|
+
const wildcardRules = rules.filter((r) => r.userAgent === "*");
|
|
33
|
+
const applicable = exactRules.length > 0 ? exactRules : wildcardRules;
|
|
34
|
+
if (applicable.length === 0) return true;
|
|
35
|
+
const matched = [];
|
|
36
|
+
for (const rule of applicable) {
|
|
37
|
+
for (const p of rule.allow) {
|
|
38
|
+
if (pathname.startsWith(p)) matched.push({ path: p, allow: true });
|
|
39
|
+
}
|
|
40
|
+
for (const p of rule.disallow) {
|
|
41
|
+
if (pathname.startsWith(p)) matched.push({ path: p, allow: false });
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
if (matched.length === 0) return true;
|
|
45
|
+
matched.sort((a, b) => b.path.length - a.path.length);
|
|
46
|
+
return matched[0].allow;
|
|
47
|
+
}
|
|
48
|
+
class RobotsCache {
|
|
49
|
+
cache = /* @__PURE__ */ new Map();
|
|
50
|
+
async isAllowed(url, userAgent = "ZeTa-Crawler") {
|
|
51
|
+
let origin;
|
|
52
|
+
let pathname;
|
|
53
|
+
try {
|
|
54
|
+
const u = new URL(url);
|
|
55
|
+
origin = u.origin;
|
|
56
|
+
pathname = u.pathname || "/";
|
|
57
|
+
} catch {
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
if (!this.cache.has(origin)) {
|
|
61
|
+
try {
|
|
62
|
+
const res = await fetch(`${origin}/robots.txt`, {
|
|
63
|
+
headers: { "User-Agent": userAgent },
|
|
64
|
+
signal: AbortSignal.timeout(5e3)
|
|
65
|
+
});
|
|
66
|
+
this.cache.set(origin, res.ok ? parseRobots(await res.text()) : []);
|
|
67
|
+
} catch {
|
|
68
|
+
this.cache.set(origin, []);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return isAllowedByRules(this.cache.get(origin), userAgent, pathname);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
export {
|
|
75
|
+
RobotsCache
|
|
76
|
+
};
|
|
77
|
+
//# sourceMappingURL=robots-txt.js.map
|