@detiq/crawler 1.1.8 → 1.1.9
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 +74 -54
- package/dist/ai-login.js +249 -227
- package/dist/auth-token-rotator.js +67 -57
- package/dist/auth.js +336 -299
- package/dist/browser-extension.js +142 -104
- package/dist/captcha-solver.js +125 -98
- package/dist/color-contrast.js +89 -77
- package/dist/content-chunker.js +126 -126
- package/dist/content-dedup.js +71 -54
- package/dist/cookie-consent.js +83 -68
- package/dist/cookie-jar.js +60 -53
- package/dist/crawler.js +5345 -4486
- package/dist/custom-js-injector.js +61 -45
- package/dist/dark-mode-capture.js +81 -67
- package/dist/dead-link-detector.js +55 -47
- package/dist/element-hash.js +12 -8
- package/dist/email-otp.js +195 -154
- package/dist/engine-router.js +98 -87
- package/dist/export/bulk-export.js +55 -70
- package/dist/export/vector-export.js +105 -77
- package/dist/extractors/advanced.js +266 -249
- package/dist/extractors/artifact-envelope.js +58 -59
- package/dist/failure-analysis-agent.js +33 -37
- package/dist/feature-flag-detector.js +66 -51
- package/dist/form-detector.js +75 -55
- package/dist/frontier.js +101 -76
- package/dist/gap-pass.js +89 -68
- package/dist/graphql-detector.js +72 -62
- package/dist/http-engine.js +330 -282
- package/dist/incremental-diff.js +87 -54
- package/dist/index.js +28 -67
- package/dist/input-fuzzer.js +97 -93
- package/dist/interactive-probe.js +827 -715
- package/dist/job-queue.js +2169 -2151
- package/dist/js-coverage.js +56 -52
- package/dist/js-router-detector.js +117 -86
- package/dist/language-detector.js +63 -51
- package/dist/link-classifier.js +50 -32
- package/dist/live-view.js +108 -91
- package/dist/locator-extractor.js +187 -151
- package/dist/logger.js +7 -9
- package/dist/markdown-extract.js +61 -48
- package/dist/native-crawler.js +946 -780
- package/dist/network-observer.js +156 -135
- package/dist/network-throttle.js +40 -29
- package/dist/openapi-generator.js +92 -77
- package/dist/pdf-extractor.js +90 -72
- package/dist/performance-metrics.js +102 -80
- package/dist/phash.js +144 -125
- package/dist/pii-scrubber.js +68 -65
- package/dist/playwright-recorder.js +58 -70
- package/dist/policies/crawl-safety.js +34 -31
- package/dist/profiles/domain-profile.js +125 -130
- package/dist/proxy.js +172 -146
- package/dist/rate-limiter.js +65 -58
- package/dist/redirect-tracker.js +64 -60
- package/dist/redis-frontier.js +136 -116
- package/dist/redis-queue.js +144 -157
- package/dist/response-cache.js +140 -127
- package/dist/responsive-detector.js +46 -42
- package/dist/retry-policy.js +50 -46
- package/dist/robots-txt.js +83 -69
- package/dist/screen-reader.js +70 -45
- package/dist/screenshot-masker.js +77 -59
- package/dist/seo-checker.js +107 -88
- package/dist/session-capture.js +212 -192
- package/dist/session-export.js +133 -107
- package/dist/sitemap-writer.js +41 -38
- package/dist/sitemap.js +202 -141
- package/dist/skyvern.js +148 -123
- package/dist/sqs-queue.js +42 -42
- package/dist/sso-token.js +90 -74
- package/dist/stealth/human-behavior.js +173 -156
- package/dist/telemetry.js +82 -68
- package/dist/test-executor.js +335 -309
- package/dist/tls-fingerprint.js +88 -77
- package/dist/url-pattern-completer.js +90 -71
- package/dist/visual-diff-verdict.js +38 -23
- package/dist/webhook-notifier.js +69 -71
- package/dist/websocket-detector.js +68 -56
- package/dist/worker.js +38 -29
- package/package.json +1 -1
package/dist/response-cache.js
CHANGED
|
@@ -1,140 +1,153 @@
|
|
|
1
|
-
import { createHash } from
|
|
2
|
-
import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, statSync, unlinkSync } from
|
|
3
|
-
import { join } from
|
|
4
|
-
class ResponseCache {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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;
|
|
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
|
+
export class ResponseCache {
|
|
5
|
+
dir;
|
|
6
|
+
totalHits = 0;
|
|
7
|
+
totalRequests = 0;
|
|
8
|
+
constructor(cacheDir) {
|
|
9
|
+
this.dir = cacheDir;
|
|
10
|
+
if (!existsSync(cacheDir))
|
|
11
|
+
mkdirSync(cacheDir, { recursive: true });
|
|
28
12
|
}
|
|
29
|
-
|
|
30
|
-
|
|
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;
|
|
13
|
+
key(url) {
|
|
14
|
+
return createHash('sha256').update(url).digest('hex').slice(0, 16);
|
|
37
15
|
}
|
|
38
|
-
|
|
39
|
-
|
|
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 {
|
|
16
|
+
path(url) {
|
|
17
|
+
return join(this.dir, `${this.key(url)}.json`);
|
|
60
18
|
}
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
19
|
+
get(url) {
|
|
20
|
+
this.totalRequests++;
|
|
21
|
+
try {
|
|
22
|
+
const p = this.path(url);
|
|
23
|
+
if (!existsSync(p))
|
|
24
|
+
return null;
|
|
25
|
+
const entry = JSON.parse(readFileSync(p, 'utf-8'));
|
|
26
|
+
this.totalHits++;
|
|
27
|
+
return entry;
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
read(url) {
|
|
34
|
+
try {
|
|
35
|
+
const p = this.path(url);
|
|
36
|
+
if (!existsSync(p))
|
|
37
|
+
return null;
|
|
38
|
+
return JSON.parse(readFileSync(p, 'utf-8'));
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
isFresh(url) {
|
|
45
|
+
const entry = this.read(url);
|
|
46
|
+
if (!entry?.cacheControl)
|
|
47
|
+
return false;
|
|
48
|
+
const m = /max-age=(\d+)/i.exec(entry.cacheControl);
|
|
49
|
+
if (!m)
|
|
50
|
+
return false;
|
|
51
|
+
const maxAgeMs = parseInt(m[1], 10) * 1000;
|
|
52
|
+
return entry.cachedAt + maxAgeMs > Date.now();
|
|
53
|
+
}
|
|
54
|
+
staleIfError(url) {
|
|
55
|
+
const entry = this.read(url);
|
|
56
|
+
if (!entry?.cacheControl)
|
|
57
|
+
return false;
|
|
58
|
+
const m = /stale-if-error=(\d+)/i.exec(entry.cacheControl);
|
|
59
|
+
if (!m)
|
|
60
|
+
return false;
|
|
61
|
+
const staleIfErrorMs = parseInt(m[1], 10) * 1000;
|
|
62
|
+
return entry.cachedAt + staleIfErrorMs >= Date.now();
|
|
63
|
+
}
|
|
64
|
+
set(url, entry) {
|
|
65
|
+
try {
|
|
66
|
+
const full = { ...entry, cachedAt: Date.now(), hitCount: 0 };
|
|
67
|
+
writeFileSync(this.path(url), JSON.stringify(full), 'utf-8');
|
|
68
|
+
}
|
|
69
|
+
catch { /* non-fatal */ }
|
|
70
|
+
}
|
|
71
|
+
getValidationHeaders(url) {
|
|
72
|
+
const entry = this.get(url);
|
|
73
|
+
if (!entry)
|
|
74
|
+
return {};
|
|
75
|
+
const headers = {};
|
|
76
|
+
if (entry.etag)
|
|
77
|
+
headers['If-None-Match'] = entry.etag;
|
|
78
|
+
if (entry.lastModified)
|
|
79
|
+
headers['If-Modified-Since'] = entry.lastModified;
|
|
80
|
+
return headers;
|
|
81
|
+
}
|
|
82
|
+
markHit(url) {
|
|
83
|
+
try {
|
|
84
|
+
const p = this.path(url);
|
|
85
|
+
if (!existsSync(p))
|
|
86
|
+
return;
|
|
87
|
+
const entry = JSON.parse(readFileSync(p, 'utf-8'));
|
|
88
|
+
entry.hitCount++;
|
|
89
|
+
writeFileSync(p, JSON.stringify(entry), 'utf-8');
|
|
90
|
+
}
|
|
91
|
+
catch { /* non-fatal */ }
|
|
78
92
|
}
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
93
|
+
invalidate(url) {
|
|
94
|
+
try {
|
|
95
|
+
const p = this.path(url);
|
|
96
|
+
if (existsSync(p))
|
|
97
|
+
unlinkSync(p);
|
|
98
|
+
}
|
|
99
|
+
catch { /* non-fatal */ }
|
|
85
100
|
}
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
101
|
+
clear() {
|
|
102
|
+
try {
|
|
103
|
+
for (const f of readdirSync(this.dir)) {
|
|
104
|
+
if (f.endsWith('.json'))
|
|
105
|
+
unlinkSync(join(this.dir, f));
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
catch { /* non-fatal */ }
|
|
93
109
|
}
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
let totalSizeBytes = 0;
|
|
98
|
-
try {
|
|
99
|
-
for (const f of readdirSync(this.dir)) {
|
|
100
|
-
if (!f.endsWith(".json")) continue;
|
|
101
|
-
entries++;
|
|
110
|
+
stats() {
|
|
111
|
+
let entries = 0;
|
|
112
|
+
let totalSizeBytes = 0;
|
|
102
113
|
try {
|
|
103
|
-
|
|
104
|
-
|
|
114
|
+
for (const f of readdirSync(this.dir)) {
|
|
115
|
+
if (!f.endsWith('.json'))
|
|
116
|
+
continue;
|
|
117
|
+
entries++;
|
|
118
|
+
try {
|
|
119
|
+
totalSizeBytes += statSync(join(this.dir, f)).size;
|
|
120
|
+
}
|
|
121
|
+
catch { /* skip */ }
|
|
122
|
+
}
|
|
105
123
|
}
|
|
106
|
-
|
|
107
|
-
|
|
124
|
+
catch { /* non-fatal */ }
|
|
125
|
+
return {
|
|
126
|
+
entries,
|
|
127
|
+
totalSizeBytes,
|
|
128
|
+
hitRate: this.totalRequests > 0 ? this.totalHits / this.totalRequests : 0,
|
|
129
|
+
totalHits: this.totalHits,
|
|
130
|
+
};
|
|
108
131
|
}
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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;
|
|
132
|
+
prune(maxAgeMs) {
|
|
133
|
+
let deleted = 0;
|
|
134
|
+
const cutoff = Date.now() - maxAgeMs;
|
|
122
135
|
try {
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
136
|
+
for (const f of readdirSync(this.dir)) {
|
|
137
|
+
if (!f.endsWith('.json'))
|
|
138
|
+
continue;
|
|
139
|
+
try {
|
|
140
|
+
const p = join(this.dir, f);
|
|
141
|
+
const entry = JSON.parse(readFileSync(p, 'utf-8'));
|
|
142
|
+
if (entry.cachedAt < cutoff) {
|
|
143
|
+
unlinkSync(p);
|
|
144
|
+
deleted++;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
catch { /* skip */ }
|
|
148
|
+
}
|
|
130
149
|
}
|
|
131
|
-
|
|
132
|
-
|
|
150
|
+
catch { /* non-fatal */ }
|
|
151
|
+
return deleted;
|
|
133
152
|
}
|
|
134
|
-
return deleted;
|
|
135
|
-
}
|
|
136
153
|
}
|
|
137
|
-
export {
|
|
138
|
-
ResponseCache
|
|
139
|
-
};
|
|
140
|
-
//# sourceMappingURL=response-cache.js.map
|
|
@@ -1,46 +1,50 @@
|
|
|
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
|
+
*/
|
|
1
9
|
const BREAKPOINTS = [
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
10
|
+
{ width: 375, height: 812, label: 'mobile' },
|
|
11
|
+
{ width: 768, height: 1024, label: 'tablet' },
|
|
12
|
+
{ width: 1280, height: 800, label: 'desktop' },
|
|
5
13
|
];
|
|
6
|
-
async function detectResponsive(page, url, opts = {}) {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
} catch {
|
|
14
|
+
export async function detectResponsive(page, url, opts = {}) {
|
|
15
|
+
const captures = [];
|
|
16
|
+
const issues = [];
|
|
17
|
+
for (const bp of BREAKPOINTS) {
|
|
18
|
+
try {
|
|
19
|
+
await page.setViewportSize({ width: bp.width, height: bp.height });
|
|
20
|
+
await page.waitForTimeout(500); // allow CSS reflow
|
|
21
|
+
const data = await page.evaluate(() => ({
|
|
22
|
+
visibleTextLength: (document.body.innerText ?? '').length,
|
|
23
|
+
hasHamburgerMenu: !!(document.querySelector('[class*="hamburger"],[class*="menu-toggle"],[class*="nav-toggle"],[aria-label*="menu"],[aria-label*="Menu"]')),
|
|
24
|
+
hiddenElements: Array.from(document.querySelectorAll('*')).filter((el) => window.getComputedStyle(el).display === 'none').length,
|
|
25
|
+
scrollHeight: document.body.scrollHeight,
|
|
26
|
+
})).catch(() => ({ visibleTextLength: 0, hasHamburgerMenu: false, hiddenElements: 0, scrollHeight: 0 }));
|
|
27
|
+
let screenshotBuffer;
|
|
28
|
+
if (opts.captureScreenshots) {
|
|
29
|
+
screenshotBuffer = await page.screenshot({ type: 'jpeg', quality: 60 }).catch(() => undefined);
|
|
30
|
+
}
|
|
31
|
+
captures.push({ ...bp, ...data, screenshotBuffer });
|
|
32
|
+
}
|
|
33
|
+
catch { /* viewport failed — skip */ }
|
|
27
34
|
}
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
35
|
+
// Restore to desktop
|
|
36
|
+
await page.setViewportSize({ width: 1280, height: 800 }).catch(() => { });
|
|
37
|
+
const mobile = captures.find(c => c.label === 'mobile');
|
|
38
|
+
const desktop = captures.find(c => c.label === 'desktop');
|
|
39
|
+
const isResponsive = !!(mobile && desktop && (mobile.hiddenElements !== desktop.hiddenElements ||
|
|
40
|
+
mobile.scrollHeight !== desktop.scrollHeight ||
|
|
41
|
+
mobile.hasHamburgerMenu));
|
|
42
|
+
const hasMobileMenu = captures.some(c => c.label === 'mobile' && c.hasHamburgerMenu);
|
|
43
|
+
if (mobile && desktop && mobile.visibleTextLength > desktop.visibleTextLength * 1.3) {
|
|
44
|
+
issues.push('Mobile shows significantly more text than desktop — possible responsive issue');
|
|
45
|
+
}
|
|
46
|
+
if (!isResponsive && captures.length > 1) {
|
|
47
|
+
issues.push('Layout appears identical across breakpoints — may not be responsive');
|
|
48
|
+
}
|
|
49
|
+
return { url, captures, isResponsive, hasMobileMenu, issues };
|
|
42
50
|
}
|
|
43
|
-
export {
|
|
44
|
-
detectResponsive
|
|
45
|
-
};
|
|
46
|
-
//# sourceMappingURL=responsive-detector.js.map
|
package/dist/retry-policy.js
CHANGED
|
@@ -1,54 +1,58 @@
|
|
|
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
|
+
*/
|
|
1
6
|
const DEFAULT_RETRY_STATUSES = [429, 500, 502, 503, 504];
|
|
2
7
|
function shouldRetry(error, retryOn) {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
8
|
+
// Network-level errors
|
|
9
|
+
if (error?.code && ['ECONNRESET', 'ECONNREFUSED', 'ETIMEDOUT', 'ENOTFOUND', 'EPIPE'].includes(error.code))
|
|
10
|
+
return true;
|
|
11
|
+
// Playwright navigation timeout
|
|
12
|
+
if (error?.message && /timeout|net::ERR_/i.test(error.message))
|
|
13
|
+
return true;
|
|
14
|
+
// HTTP status errors
|
|
15
|
+
if (typeof error?.status === 'number')
|
|
16
|
+
return retryOn.includes(error.status);
|
|
17
|
+
return false;
|
|
7
18
|
}
|
|
8
19
|
function delay(ms) {
|
|
9
|
-
|
|
20
|
+
return new Promise(r => setTimeout(r, ms));
|
|
10
21
|
}
|
|
11
|
-
async function withRetry(fn, opts = {}) {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
totalDelayMs += waitMs;
|
|
33
|
-
await delay(waitMs);
|
|
22
|
+
export async function withRetry(fn, opts = {}) {
|
|
23
|
+
const { maxRetries = 3, baseDelayMs = 1_000, maxDelayMs = 30_000, jitterMs = 500, retryOn = DEFAULT_RETRY_STATUSES, } = opts;
|
|
24
|
+
let lastError;
|
|
25
|
+
let totalDelayMs = 0;
|
|
26
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
27
|
+
try {
|
|
28
|
+
const value = await fn(attempt);
|
|
29
|
+
return { value, attempts: attempt + 1, totalDelayMs };
|
|
30
|
+
}
|
|
31
|
+
catch (err) {
|
|
32
|
+
lastError = err;
|
|
33
|
+
if (attempt === maxRetries)
|
|
34
|
+
break;
|
|
35
|
+
if (!shouldRetry(err, retryOn))
|
|
36
|
+
throw err; // non-retryable — fail fast
|
|
37
|
+
const backoff = Math.min(maxDelayMs, baseDelayMs * Math.pow(2, attempt));
|
|
38
|
+
const jitter = Math.random() * jitterMs;
|
|
39
|
+
const waitMs = Math.round(backoff + jitter);
|
|
40
|
+
totalDelayMs += waitMs;
|
|
41
|
+
await delay(waitMs);
|
|
42
|
+
}
|
|
34
43
|
}
|
|
35
|
-
|
|
36
|
-
throw lastError;
|
|
44
|
+
throw lastError;
|
|
37
45
|
}
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
46
|
+
/** Wraps fetch() with retry policy */
|
|
47
|
+
export async function fetchWithRetry(url, init = {}, retryOpts = {}) {
|
|
48
|
+
const result = await withRetry(async () => {
|
|
49
|
+
const res = await fetch(url, init);
|
|
50
|
+
if (retryOpts.retryOn?.includes(res.status) ?? DEFAULT_RETRY_STATUSES.includes(res.status)) {
|
|
51
|
+
const err = new Error(`HTTP ${res.status}`);
|
|
52
|
+
err.status = res.status;
|
|
53
|
+
throw err;
|
|
54
|
+
}
|
|
55
|
+
return res;
|
|
56
|
+
}, retryOpts);
|
|
57
|
+
return result.value;
|
|
49
58
|
}
|
|
50
|
-
export {
|
|
51
|
-
fetchWithRetry,
|
|
52
|
-
withRetry
|
|
53
|
-
};
|
|
54
|
-
//# sourceMappingURL=retry-policy.js.map
|
package/dist/robots-txt.js
CHANGED
|
@@ -1,77 +1,91 @@
|
|
|
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
|
+
*/
|
|
1
6
|
function parseRobots(text) {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
7
|
+
const rules = [];
|
|
8
|
+
let current = null;
|
|
9
|
+
for (const rawLine of text.split('\n')) {
|
|
10
|
+
const line = rawLine.split('#')[0].trim();
|
|
11
|
+
if (!line) {
|
|
12
|
+
current = null;
|
|
13
|
+
continue;
|
|
14
|
+
}
|
|
15
|
+
const colonIdx = line.indexOf(':');
|
|
16
|
+
if (colonIdx === -1)
|
|
17
|
+
continue;
|
|
18
|
+
const key = line.slice(0, colonIdx).trim().toLowerCase();
|
|
19
|
+
const value = line.slice(colonIdx + 1).trim();
|
|
20
|
+
if (key === 'user-agent') {
|
|
21
|
+
// Start new block only when current block already has rules, otherwise
|
|
22
|
+
// accumulate multiple User-agent lines into one block.
|
|
23
|
+
if (!current || current.disallow.length > 0 || current.allow.length > 0) {
|
|
24
|
+
current = { userAgent: value.toLowerCase(), disallow: [], allow: [] };
|
|
25
|
+
rules.push(current);
|
|
26
|
+
}
|
|
27
|
+
else {
|
|
28
|
+
current.userAgent = value.toLowerCase();
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
else if (key === 'disallow' && current && value) {
|
|
32
|
+
current.disallow.push(value);
|
|
33
|
+
}
|
|
34
|
+
else if (key === 'allow' && current && value) {
|
|
35
|
+
current.allow.push(value);
|
|
36
|
+
}
|
|
9
37
|
}
|
|
10
|
-
|
|
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;
|
|
38
|
+
return rules;
|
|
28
39
|
}
|
|
29
40
|
function isAllowedByRules(rules, userAgent, pathname) {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
41
|
+
const ua = userAgent.toLowerCase();
|
|
42
|
+
const exactRules = rules.filter(r => r.userAgent === ua);
|
|
43
|
+
const wildcardRules = rules.filter(r => r.userAgent === '*');
|
|
44
|
+
const applicable = exactRules.length > 0 ? exactRules : wildcardRules;
|
|
45
|
+
if (applicable.length === 0)
|
|
46
|
+
return true;
|
|
47
|
+
// Collect all matched allow/disallow directives; longest path wins.
|
|
48
|
+
const matched = [];
|
|
49
|
+
for (const rule of applicable) {
|
|
50
|
+
for (const p of rule.allow) {
|
|
51
|
+
if (pathname.startsWith(p))
|
|
52
|
+
matched.push({ path: p, allow: true });
|
|
53
|
+
}
|
|
54
|
+
for (const p of rule.disallow) {
|
|
55
|
+
if (pathname.startsWith(p))
|
|
56
|
+
matched.push({ path: p, allow: false });
|
|
57
|
+
}
|
|
42
58
|
}
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
59
|
+
if (matched.length === 0)
|
|
60
|
+
return true;
|
|
61
|
+
matched.sort((a, b) => b.path.length - a.path.length);
|
|
62
|
+
return matched[0].allow;
|
|
47
63
|
}
|
|
48
|
-
class RobotsCache {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
64
|
+
export class RobotsCache {
|
|
65
|
+
cache = new Map();
|
|
66
|
+
async isAllowed(url, userAgent = 'ZeTa-Crawler') {
|
|
67
|
+
let origin;
|
|
68
|
+
let pathname;
|
|
69
|
+
try {
|
|
70
|
+
const u = new URL(url);
|
|
71
|
+
origin = u.origin;
|
|
72
|
+
pathname = u.pathname || '/';
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
if (!this.cache.has(origin)) {
|
|
78
|
+
try {
|
|
79
|
+
const res = await fetch(`${origin}/robots.txt`, {
|
|
80
|
+
headers: { 'User-Agent': userAgent },
|
|
81
|
+
signal: AbortSignal.timeout(5_000),
|
|
82
|
+
});
|
|
83
|
+
this.cache.set(origin, res.ok ? parseRobots(await res.text()) : []);
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
this.cache.set(origin, []); // network error or timeout = no restrictions
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return isAllowedByRules(this.cache.get(origin), userAgent, pathname);
|
|
70
90
|
}
|
|
71
|
-
return isAllowedByRules(this.cache.get(origin), userAgent, pathname);
|
|
72
|
-
}
|
|
73
91
|
}
|
|
74
|
-
export {
|
|
75
|
-
RobotsCache
|
|
76
|
-
};
|
|
77
|
-
//# sourceMappingURL=robots-txt.js.map
|