@detiq/crawler 1.1.9 → 1.1.10
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/dist/accessibility-auditor.js +54 -74
- package/dist/ai-login.js +227 -249
- package/dist/auth-token-rotator.js +57 -67
- package/dist/auth.js +299 -336
- package/dist/browser-extension.js +104 -142
- package/dist/captcha-solver.js +98 -125
- package/dist/color-contrast.js +77 -89
- package/dist/content-chunker.js +126 -126
- package/dist/content-dedup.js +54 -71
- package/dist/cookie-consent.js +68 -83
- package/dist/cookie-jar.js +53 -60
- package/dist/crawler.js +4494 -5353
- package/dist/custom-js-injector.js +45 -61
- package/dist/dark-mode-capture.js +67 -81
- package/dist/dead-link-detector.js +47 -55
- package/dist/element-hash.js +8 -12
- package/dist/email-otp.js +154 -195
- package/dist/engine-router.js +87 -98
- package/dist/export/bulk-export.js +70 -55
- package/dist/export/vector-export.js +77 -105
- package/dist/extractors/advanced.js +249 -266
- package/dist/extractors/artifact-envelope.js +59 -58
- package/dist/failure-analysis-agent.js +37 -33
- package/dist/feature-flag-detector.js +51 -66
- package/dist/form-detector.js +55 -75
- package/dist/frontier.js +76 -101
- package/dist/gap-pass.js +68 -89
- package/dist/graphql-detector.js +62 -72
- package/dist/http-engine.js +282 -330
- package/dist/incremental-diff.js +54 -87
- package/dist/index.js +67 -28
- package/dist/input-fuzzer.js +93 -97
- package/dist/interactive-probe.js +715 -827
- package/dist/job-queue.js +2151 -2169
- package/dist/js-coverage.js +52 -56
- package/dist/js-router-detector.js +86 -117
- package/dist/language-detector.js +51 -63
- package/dist/link-classifier.js +32 -50
- package/dist/live-view.js +91 -108
- package/dist/locator-extractor.js +151 -187
- package/dist/logger.js +9 -7
- package/dist/markdown-extract.js +48 -61
- package/dist/native-crawler.js +780 -946
- package/dist/network-observer.js +135 -156
- package/dist/network-throttle.js +29 -40
- package/dist/openapi-generator.js +77 -92
- package/dist/pdf-extractor.js +72 -90
- package/dist/performance-metrics.js +80 -102
- package/dist/phash.js +125 -144
- package/dist/pii-scrubber.js +65 -68
- package/dist/playwright-recorder.js +70 -58
- package/dist/policies/crawl-safety.js +31 -34
- package/dist/profiles/domain-profile.js +130 -125
- package/dist/proxy.js +146 -172
- package/dist/rate-limiter.js +58 -65
- package/dist/redirect-tracker.js +60 -64
- package/dist/redis-frontier.js +116 -136
- package/dist/redis-queue.js +157 -144
- package/dist/response-cache.js +127 -140
- package/dist/responsive-detector.js +42 -46
- package/dist/retry-policy.js +46 -50
- package/dist/robots-txt.js +69 -83
- package/dist/screen-reader.js +45 -70
- package/dist/screenshot-masker.js +59 -77
- package/dist/seo-checker.js +88 -107
- package/dist/session-capture.js +192 -212
- package/dist/session-export.js +107 -133
- package/dist/sitemap-writer.js +38 -41
- package/dist/sitemap.js +141 -202
- package/dist/skyvern.js +123 -148
- package/dist/sqs-queue.js +42 -42
- package/dist/sso-token.js +74 -90
- package/dist/stealth/human-behavior.js +156 -173
- package/dist/telemetry.js +68 -82
- package/dist/test-executor.js +309 -335
- package/dist/tls-fingerprint.js +77 -88
- package/dist/url-pattern-completer.js +71 -90
- package/dist/visual-diff-verdict.js +23 -38
- package/dist/webhook-notifier.js +71 -69
- package/dist/websocket-detector.js +56 -68
- package/dist/worker.js +29 -38
- package/package.json +1 -1
package/dist/proxy.js
CHANGED
|
@@ -1,191 +1,165 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
*
|
|
4
|
-
* Source: proxifly/free-proxy-list (MIT) via jsDelivr CDN.
|
|
5
|
-
* These are datacenter proxies — they bypass simple IP blocks but NOT
|
|
6
|
-
* full Cloudflare Bot Manager / Akamai. For Cloudflare-protected sites,
|
|
7
|
-
* users need a residential proxy (Bright Data, Oxylabs, etc.).
|
|
8
|
-
*/
|
|
9
|
-
import { createConnection } from 'net';
|
|
10
|
-
const PROXY_LIST_URL = 'https://cdn.jsdelivr.net/gh/proxifly/free-proxy-list@main/proxies/all/data.json';
|
|
11
|
-
/** Fetch and filter proxy list. Returns http proxies sorted by speed (fastest first). */
|
|
1
|
+
import { createConnection } from "net";
|
|
2
|
+
const PROXY_LIST_URL = "https://cdn.jsdelivr.net/gh/proxifly/free-proxy-list@main/proxies/all/data.json";
|
|
12
3
|
async function fetchCandidates() {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
return raw
|
|
21
|
-
.filter((p) => p.protocols?.includes('http') && p.port > 0 && (p.upTime ?? 0) >= 30)
|
|
22
|
-
.sort((a, b) => (a.speed ?? 99999) - (b.speed ?? 99999))
|
|
23
|
-
.slice(0, 50); // test at most 50 candidates
|
|
4
|
+
const res = await fetch(PROXY_LIST_URL, {
|
|
5
|
+
signal: AbortSignal.timeout(1e4),
|
|
6
|
+
headers: { "Accept": "application/json" }
|
|
7
|
+
});
|
|
8
|
+
if (!res.ok) throw new Error(`proxy list fetch failed: ${res.status}`);
|
|
9
|
+
const raw = await res.json();
|
|
10
|
+
return raw.filter((p) => p.protocols?.includes("http") && p.port > 0 && (p.upTime ?? 0) >= 30).sort((a, b) => (a.speed ?? 99999) - (b.speed ?? 99999)).slice(0, 50);
|
|
24
11
|
}
|
|
25
|
-
/** TCP-level proxy liveness check via HTTP CONNECT tunnel. No extra dependencies. */
|
|
26
12
|
function testProxy(ip, port, timeoutMs) {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
13
|
+
return new Promise((resolve) => {
|
|
14
|
+
let settled = false;
|
|
15
|
+
const settle = (ok) => {
|
|
16
|
+
if (settled) return;
|
|
17
|
+
settled = true;
|
|
18
|
+
resolve(ok);
|
|
19
|
+
try {
|
|
20
|
+
sock.destroy();
|
|
21
|
+
} catch {
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
const timer = setTimeout(() => settle(false), timeoutMs);
|
|
25
|
+
const sock = createConnection({ host: ip, port, timeout: timeoutMs });
|
|
26
|
+
sock.on("connect", () => {
|
|
27
|
+
sock.write("CONNECT example.com:80 HTTP/1.1\r\nHost: example.com:80\r\n\r\n");
|
|
28
|
+
sock.once("data", (data) => {
|
|
29
|
+
clearTimeout(timer);
|
|
30
|
+
const ok = /^HTTP\/1\.[01] 200/.test(data.toString());
|
|
31
|
+
settle(ok);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
sock.on("error", () => {
|
|
35
|
+
clearTimeout(timer);
|
|
36
|
+
settle(false);
|
|
51
37
|
});
|
|
38
|
+
sock.on("timeout", () => {
|
|
39
|
+
clearTimeout(timer);
|
|
40
|
+
settle(false);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
52
43
|
}
|
|
53
|
-
/** Test `batchSize` proxies concurrently and return first working one. */
|
|
54
44
|
async function testBatch(candidates, start, batchSize, timeoutMs) {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
})
|
|
62
|
-
|
|
45
|
+
const batch = candidates.slice(start, start + batchSize);
|
|
46
|
+
if (batch.length === 0) return null;
|
|
47
|
+
const results = await Promise.all(
|
|
48
|
+
batch.map(async (p) => {
|
|
49
|
+
const ok = await testProxy(p.ip, p.port, timeoutMs);
|
|
50
|
+
return ok ? `http://${p.ip}:${p.port}` : null;
|
|
51
|
+
})
|
|
52
|
+
);
|
|
53
|
+
return results.find((r) => r !== null) ?? null;
|
|
63
54
|
}
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
try {
|
|
71
|
-
candidates = await fetchCandidates();
|
|
72
|
-
}
|
|
73
|
-
catch (err) {
|
|
74
|
-
console.warn('[proxy] failed to fetch proxy list:', err?.message ?? err);
|
|
75
|
-
return null;
|
|
76
|
-
}
|
|
77
|
-
const BATCH = 10;
|
|
78
|
-
const TIMEOUT = 4_000;
|
|
79
|
-
for (let i = 0; i < candidates.length; i += BATCH) {
|
|
80
|
-
const found = await testBatch(candidates, i, BATCH, TIMEOUT);
|
|
81
|
-
if (found) {
|
|
82
|
-
console.info(`[proxy] selected free proxy: ${found} (tested ${i + BATCH} candidates)`);
|
|
83
|
-
return found;
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
console.warn('[proxy] no working free proxy found in list');
|
|
55
|
+
async function pickFreeProxy() {
|
|
56
|
+
let candidates;
|
|
57
|
+
try {
|
|
58
|
+
candidates = await fetchCandidates();
|
|
59
|
+
} catch (err) {
|
|
60
|
+
console.warn("[proxy] failed to fetch proxy list:", err?.message ?? err);
|
|
87
61
|
return null;
|
|
62
|
+
}
|
|
63
|
+
const BATCH = 10;
|
|
64
|
+
const TIMEOUT = 4e3;
|
|
65
|
+
for (let i = 0; i < candidates.length; i += BATCH) {
|
|
66
|
+
const found = await testBatch(candidates, i, BATCH, TIMEOUT);
|
|
67
|
+
if (found) {
|
|
68
|
+
console.info(`[proxy] selected free proxy: ${found} (tested ${i + BATCH} candidates)`);
|
|
69
|
+
return found;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
console.warn("[proxy] no working free proxy found in list");
|
|
73
|
+
return null;
|
|
88
74
|
}
|
|
89
|
-
const TIER_ORDER = [
|
|
75
|
+
const TIER_ORDER = ["free", "datacenter", "residential", "premium"];
|
|
90
76
|
function nextTier(current) {
|
|
91
|
-
|
|
92
|
-
|
|
77
|
+
const idx = TIER_ORDER.indexOf(current);
|
|
78
|
+
return TIER_ORDER[Math.min(idx + 1, TIER_ORDER.length - 1)];
|
|
93
79
|
}
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
*
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
* mgr.recordResult('api.salesforce.com', success);
|
|
105
|
-
*/
|
|
106
|
-
export class TieredProxyManager {
|
|
107
|
-
tiers = new Map();
|
|
108
|
-
domainHistory = new Map();
|
|
109
|
-
ESCALATE_AFTER = 3; // consecutive failures before escalation
|
|
110
|
-
DECAY_AFTER_MS = 30 * 60 * 1000; // 30 min: probe tier downgrade
|
|
111
|
-
constructor(configs) {
|
|
112
|
-
for (const cfg of configs) {
|
|
113
|
-
if (cfg.urls.length > 0)
|
|
114
|
-
this.tiers.set(cfg.tier, cfg.urls);
|
|
115
|
-
}
|
|
80
|
+
class TieredProxyManager {
|
|
81
|
+
tiers = /* @__PURE__ */ new Map();
|
|
82
|
+
domainHistory = /* @__PURE__ */ new Map();
|
|
83
|
+
ESCALATE_AFTER = 3;
|
|
84
|
+
// consecutive failures before escalation
|
|
85
|
+
DECAY_AFTER_MS = 30 * 60 * 1e3;
|
|
86
|
+
// 30 min: probe tier downgrade
|
|
87
|
+
constructor(configs) {
|
|
88
|
+
for (const cfg of configs) {
|
|
89
|
+
if (cfg.urls.length > 0) this.tiers.set(cfg.tier, cfg.urls);
|
|
116
90
|
}
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
return this.domainHistory.get(domain);
|
|
91
|
+
}
|
|
92
|
+
getHistory(domain) {
|
|
93
|
+
if (!this.domainHistory.has(domain)) {
|
|
94
|
+
this.domainHistory.set(domain, {
|
|
95
|
+
tier: this.lowestAvailableTier(),
|
|
96
|
+
consecutiveFailures: 0,
|
|
97
|
+
lastEscalatedAt: 0,
|
|
98
|
+
totalRequests: 0,
|
|
99
|
+
successRate: 1
|
|
100
|
+
});
|
|
128
101
|
}
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
return 'free';
|
|
102
|
+
return this.domainHistory.get(domain);
|
|
103
|
+
}
|
|
104
|
+
lowestAvailableTier() {
|
|
105
|
+
for (const t of TIER_ORDER) {
|
|
106
|
+
if (this.tiers.has(t)) return t;
|
|
135
107
|
}
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
}
|
|
108
|
+
return "free";
|
|
109
|
+
}
|
|
110
|
+
/** Pick a proxy URL for this domain, based on its tier history. */
|
|
111
|
+
selectProxy(domain) {
|
|
112
|
+
const history = this.getHistory(domain);
|
|
113
|
+
if (history.consecutiveFailures === 0 && history.lastEscalatedAt > 0) {
|
|
114
|
+
const elapsed = Date.now() - history.lastEscalatedAt;
|
|
115
|
+
if (elapsed > this.DECAY_AFTER_MS) {
|
|
116
|
+
const currentIdx = TIER_ORDER.indexOf(history.tier);
|
|
117
|
+
if (currentIdx > 0) {
|
|
118
|
+
const lowerTier = TIER_ORDER[currentIdx - 1];
|
|
119
|
+
if (this.tiers.has(lowerTier)) {
|
|
120
|
+
history.tier = lowerTier;
|
|
121
|
+
history.lastEscalatedAt = 0;
|
|
122
|
+
}
|
|
152
123
|
}
|
|
153
|
-
|
|
154
|
-
if (!urls || urls.length === 0)
|
|
155
|
-
return null;
|
|
156
|
-
// Round-robin within tier
|
|
157
|
-
const idx = history.totalRequests % urls.length;
|
|
158
|
-
return urls[idx];
|
|
124
|
+
}
|
|
159
125
|
}
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
126
|
+
const urls = this.tiers.get(history.tier);
|
|
127
|
+
if (!urls || urls.length === 0) return null;
|
|
128
|
+
const idx = history.totalRequests % urls.length;
|
|
129
|
+
return urls[idx];
|
|
130
|
+
}
|
|
131
|
+
/** Record a request result for a domain. Triggers tier escalation on repeated failures. */
|
|
132
|
+
recordResult(domain, success) {
|
|
133
|
+
const history = this.getHistory(domain);
|
|
134
|
+
history.totalRequests++;
|
|
135
|
+
if (success) {
|
|
136
|
+
history.consecutiveFailures = 0;
|
|
137
|
+
history.successRate = history.successRate * 0.9 + 0.1;
|
|
138
|
+
} else {
|
|
139
|
+
history.consecutiveFailures++;
|
|
140
|
+
history.successRate = history.successRate * 0.9;
|
|
141
|
+
if (history.consecutiveFailures >= this.ESCALATE_AFTER) {
|
|
142
|
+
const elevated = nextTier(history.tier);
|
|
143
|
+
if (elevated !== history.tier && this.tiers.has(elevated)) {
|
|
144
|
+
console.warn(`[proxy-tier] domain=${domain} escalating ${history.tier}\u2192${elevated} after ${history.consecutiveFailures} failures`);
|
|
145
|
+
history.tier = elevated;
|
|
146
|
+
history.lastEscalatedAt = Date.now();
|
|
147
|
+
history.consecutiveFailures = 0;
|
|
168
148
|
}
|
|
169
|
-
|
|
170
|
-
history.consecutiveFailures++;
|
|
171
|
-
history.successRate = history.successRate * 0.9;
|
|
172
|
-
if (history.consecutiveFailures >= this.ESCALATE_AFTER) {
|
|
173
|
-
const elevated = nextTier(history.tier);
|
|
174
|
-
if (elevated !== history.tier && this.tiers.has(elevated)) {
|
|
175
|
-
console.warn(`[proxy-tier] domain=${domain} escalating ${history.tier}→${elevated} after ${history.consecutiveFailures} failures`);
|
|
176
|
-
history.tier = elevated;
|
|
177
|
-
history.lastEscalatedAt = Date.now();
|
|
178
|
-
history.consecutiveFailures = 0;
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
}
|
|
183
|
-
stats() {
|
|
184
|
-
return Array.from(this.domainHistory.entries()).map(([domain, h]) => ({
|
|
185
|
-
domain,
|
|
186
|
-
tier: h.tier,
|
|
187
|
-
successRate: Math.round(h.successRate * 100) / 100,
|
|
188
|
-
totalRequests: h.totalRequests,
|
|
189
|
-
}));
|
|
149
|
+
}
|
|
190
150
|
}
|
|
151
|
+
}
|
|
152
|
+
stats() {
|
|
153
|
+
return Array.from(this.domainHistory.entries()).map(([domain, h]) => ({
|
|
154
|
+
domain,
|
|
155
|
+
tier: h.tier,
|
|
156
|
+
successRate: Math.round(h.successRate * 100) / 100,
|
|
157
|
+
totalRequests: h.totalRequests
|
|
158
|
+
}));
|
|
159
|
+
}
|
|
191
160
|
}
|
|
161
|
+
export {
|
|
162
|
+
TieredProxyManager,
|
|
163
|
+
pickFreeProxy
|
|
164
|
+
};
|
|
165
|
+
//# sourceMappingURL=proxy.js.map
|
package/dist/rate-limiter.js
CHANGED
|
@@ -1,71 +1,64 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Domain-aware rate limiter — ensures the crawler respects per-domain rate limits.
|
|
3
|
-
*
|
|
4
|
-
* Uses a token bucket algorithm per domain. When the bucket is empty, requests
|
|
5
|
-
* wait until a token is available. Tokens refill at a configurable rate.
|
|
6
|
-
*
|
|
7
|
-
* Default: 2 requests/second per domain, burst of 5.
|
|
8
|
-
* Can be configured per-domain via the rateMap (domain → requests/second).
|
|
9
|
-
*/
|
|
10
1
|
function extractDomain(url) {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
}
|
|
2
|
+
try {
|
|
3
|
+
return new URL(url).hostname;
|
|
4
|
+
} catch {
|
|
5
|
+
return url;
|
|
6
|
+
}
|
|
17
7
|
}
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
}
|
|
32
|
-
return this.buckets.get(domain);
|
|
33
|
-
}
|
|
34
|
-
refill(domain, bucket) {
|
|
35
|
-
const rps = this.rateMap[domain] ?? this.defaultRps;
|
|
36
|
-
const now = Date.now();
|
|
37
|
-
const elapsed = (now - bucket.lastRefill) / 1000;
|
|
38
|
-
bucket.tokens = Math.min(this.burstSize, bucket.tokens + elapsed * rps);
|
|
39
|
-
bucket.lastRefill = now;
|
|
40
|
-
}
|
|
41
|
-
/** Wait until a token is available for the given URL's domain. */
|
|
42
|
-
async throttle(url) {
|
|
43
|
-
const domain = extractDomain(url);
|
|
44
|
-
const bucket = this.getBucket(domain);
|
|
45
|
-
this.refill(domain, bucket);
|
|
46
|
-
if (bucket.tokens >= 1) {
|
|
47
|
-
bucket.tokens -= 1;
|
|
48
|
-
return;
|
|
49
|
-
}
|
|
50
|
-
// Calculate wait time until next token
|
|
51
|
-
const rps = this.rateMap[domain] ?? this.defaultRps;
|
|
52
|
-
const waitMs = Math.ceil((1 - bucket.tokens) / rps * 1000);
|
|
53
|
-
await new Promise(r => setTimeout(r, waitMs));
|
|
54
|
-
bucket.tokens = 0;
|
|
55
|
-
bucket.lastRefill = Date.now();
|
|
8
|
+
class DomainRateLimiter {
|
|
9
|
+
buckets = /* @__PURE__ */ new Map();
|
|
10
|
+
defaultRps;
|
|
11
|
+
burstSize;
|
|
12
|
+
rateMap;
|
|
13
|
+
constructor(opts = {}) {
|
|
14
|
+
this.defaultRps = opts.defaultRps ?? 2;
|
|
15
|
+
this.burstSize = opts.burstSize ?? 5;
|
|
16
|
+
this.rateMap = opts.rateMap ?? {};
|
|
17
|
+
}
|
|
18
|
+
getBucket(domain) {
|
|
19
|
+
if (!this.buckets.has(domain)) {
|
|
20
|
+
this.buckets.set(domain, { tokens: this.burstSize, lastRefill: Date.now() });
|
|
56
21
|
}
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
22
|
+
return this.buckets.get(domain);
|
|
23
|
+
}
|
|
24
|
+
refill(domain, bucket) {
|
|
25
|
+
const rps = this.rateMap[domain] ?? this.defaultRps;
|
|
26
|
+
const now = Date.now();
|
|
27
|
+
const elapsed = (now - bucket.lastRefill) / 1e3;
|
|
28
|
+
bucket.tokens = Math.min(this.burstSize, bucket.tokens + elapsed * rps);
|
|
29
|
+
bucket.lastRefill = now;
|
|
30
|
+
}
|
|
31
|
+
/** Wait until a token is available for the given URL's domain. */
|
|
32
|
+
async throttle(url) {
|
|
33
|
+
const domain = extractDomain(url);
|
|
34
|
+
const bucket = this.getBucket(domain);
|
|
35
|
+
this.refill(domain, bucket);
|
|
36
|
+
if (bucket.tokens >= 1) {
|
|
37
|
+
bucket.tokens -= 1;
|
|
38
|
+
return;
|
|
62
39
|
}
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
40
|
+
const rps = this.rateMap[domain] ?? this.defaultRps;
|
|
41
|
+
const waitMs = Math.ceil((1 - bucket.tokens) / rps * 1e3);
|
|
42
|
+
await new Promise((r) => setTimeout(r, waitMs));
|
|
43
|
+
bucket.tokens = 0;
|
|
44
|
+
bucket.lastRefill = Date.now();
|
|
45
|
+
}
|
|
46
|
+
/** Override rate for a specific domain at runtime (e.g. after seeing 429). */
|
|
47
|
+
setRate(domain, rps) {
|
|
48
|
+
this.rateMap[domain] = rps;
|
|
49
|
+
const bucket = this.getBucket(domain);
|
|
50
|
+
bucket.tokens = 0;
|
|
51
|
+
}
|
|
52
|
+
stats() {
|
|
53
|
+
const result = {};
|
|
54
|
+
for (const [domain, bucket] of this.buckets) {
|
|
55
|
+
this.refill(domain, bucket);
|
|
56
|
+
result[domain] = { tokens: Math.round(bucket.tokens * 100) / 100, rps: this.rateMap[domain] ?? this.defaultRps };
|
|
70
57
|
}
|
|
58
|
+
return result;
|
|
59
|
+
}
|
|
71
60
|
}
|
|
61
|
+
export {
|
|
62
|
+
DomainRateLimiter
|
|
63
|
+
};
|
|
64
|
+
//# sourceMappingURL=rate-limiter.js.map
|
package/dist/redirect-tracker.js
CHANGED
|
@@ -1,69 +1,65 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
const onResponse = (response) => {
|
|
14
|
-
const status = response.status();
|
|
15
|
-
if (status >= 300 && status < 400) {
|
|
16
|
-
const from = response.url();
|
|
17
|
-
const to = response.headers()['location'] ?? '';
|
|
18
|
-
const now = Date.now();
|
|
19
|
-
hops.push({ from, to, statusCode: status, durationMs: now - lastResponseTime });
|
|
20
|
-
lastResponseTime = now;
|
|
21
|
-
}
|
|
22
|
-
};
|
|
23
|
-
page.on('response', onResponse);
|
|
24
|
-
try {
|
|
25
|
-
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30_000 }).catch(() => { });
|
|
26
|
-
}
|
|
27
|
-
finally {
|
|
28
|
-
page.off('response', onResponse);
|
|
1
|
+
async function trackRedirects(page, url) {
|
|
2
|
+
const hops = [];
|
|
3
|
+
const seen = /* @__PURE__ */ new Set();
|
|
4
|
+
let lastResponseTime = Date.now();
|
|
5
|
+
const onResponse = (response) => {
|
|
6
|
+
const status = response.status();
|
|
7
|
+
if (status >= 300 && status < 400) {
|
|
8
|
+
const from = response.url();
|
|
9
|
+
const to = response.headers()["location"] ?? "";
|
|
10
|
+
const now = Date.now();
|
|
11
|
+
hops.push({ from, to, statusCode: status, durationMs: now - lastResponseTime });
|
|
12
|
+
lastResponseTime = now;
|
|
29
13
|
}
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
seen.add(h.from);
|
|
35
|
-
return false;
|
|
14
|
+
};
|
|
15
|
+
page.on("response", onResponse);
|
|
16
|
+
try {
|
|
17
|
+
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 3e4 }).catch(() => {
|
|
36
18
|
});
|
|
19
|
+
} finally {
|
|
20
|
+
page.off("response", onResponse);
|
|
21
|
+
}
|
|
22
|
+
const finalUrl = page.url();
|
|
23
|
+
const isCircular = hops.some((h) => {
|
|
24
|
+
if (seen.has(h.to)) return true;
|
|
25
|
+
seen.add(h.from);
|
|
26
|
+
return false;
|
|
27
|
+
});
|
|
28
|
+
return {
|
|
29
|
+
originalUrl: url,
|
|
30
|
+
finalUrl,
|
|
31
|
+
hops,
|
|
32
|
+
isCircular,
|
|
33
|
+
hopCount: hops.length,
|
|
34
|
+
totalDurationMs: hops.reduce((sum, h) => sum + h.durationMs, 0)
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
class RedirectRegistry {
|
|
38
|
+
chains = /* @__PURE__ */ new Map();
|
|
39
|
+
register(chain) {
|
|
40
|
+
this.chains.set(chain.originalUrl, chain);
|
|
41
|
+
}
|
|
42
|
+
get(url) {
|
|
43
|
+
return this.chains.get(url);
|
|
44
|
+
}
|
|
45
|
+
getAll() {
|
|
46
|
+
return [...this.chains.values()];
|
|
47
|
+
}
|
|
48
|
+
getProblematic() {
|
|
49
|
+
return [...this.chains.values()].filter((c) => c.isCircular || c.hopCount > 3);
|
|
50
|
+
}
|
|
51
|
+
stats() {
|
|
52
|
+
const all = [...this.chains.values()];
|
|
37
53
|
return {
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
hopCount: hops.length,
|
|
43
|
-
totalDurationMs: hops.reduce((sum, h) => sum + h.durationMs, 0),
|
|
54
|
+
total: all.length,
|
|
55
|
+
withRedirects: all.filter((c) => c.hopCount > 0).length,
|
|
56
|
+
circular: all.filter((c) => c.isCircular).length,
|
|
57
|
+
deepChains: all.filter((c) => c.hopCount > 3).length
|
|
44
58
|
};
|
|
59
|
+
}
|
|
45
60
|
}
|
|
46
|
-
export
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
get(url) {
|
|
52
|
-
return this.chains.get(url);
|
|
53
|
-
}
|
|
54
|
-
getAll() {
|
|
55
|
-
return [...this.chains.values()];
|
|
56
|
-
}
|
|
57
|
-
getProblematic() {
|
|
58
|
-
return [...this.chains.values()].filter(c => c.isCircular || c.hopCount > 3);
|
|
59
|
-
}
|
|
60
|
-
stats() {
|
|
61
|
-
const all = [...this.chains.values()];
|
|
62
|
-
return {
|
|
63
|
-
total: all.length,
|
|
64
|
-
withRedirects: all.filter(c => c.hopCount > 0).length,
|
|
65
|
-
circular: all.filter(c => c.isCircular).length,
|
|
66
|
-
deepChains: all.filter(c => c.hopCount > 3).length,
|
|
67
|
-
};
|
|
68
|
-
}
|
|
69
|
-
}
|
|
61
|
+
export {
|
|
62
|
+
RedirectRegistry,
|
|
63
|
+
trackRedirects
|
|
64
|
+
};
|
|
65
|
+
//# sourceMappingURL=redirect-tracker.js.map
|