@detiq/crawler 1.1.9 → 1.1.11
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/src/job-queue.ts +136 -0
package/dist/http-engine.js
CHANGED
|
@@ -1,353 +1,305 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
* - ETag / Last-Modified response caching
|
|
10
|
-
* - 429 / 503 exponential backoff with jitter
|
|
11
|
-
* - BFS / DFS traversal
|
|
12
|
-
* - URL regex filtering
|
|
13
|
-
* - Robots.txt compliance
|
|
14
|
-
* - Link discovery (href, action, src)
|
|
15
|
-
* - Markdown extraction (Readability + Turndown)
|
|
16
|
-
* - OG / schema.org / meta extraction
|
|
17
|
-
* - Table → JSON extraction
|
|
18
|
-
* - JSON Lines, CSV, raw HTML output formats
|
|
19
|
-
* - Streaming progress via onProgress callback
|
|
20
|
-
*/
|
|
21
|
-
import { Readability } from '@mozilla/readability';
|
|
22
|
-
import TurndownService from 'turndown';
|
|
23
|
-
import { parseHTML } from 'linkedom';
|
|
24
|
-
import { RobotsCache } from './robots-txt.js';
|
|
25
|
-
import { ResponseCache } from './response-cache.js';
|
|
26
|
-
import { extractMetadata, extractTables, extractImages } from './extractors/advanced.js';
|
|
27
|
-
import { CHROME_CIPHERS, CHROME_CURVES } from './tls-fingerprint.js';
|
|
28
|
-
import { renderingPredictor } from './engine-router.js';
|
|
29
|
-
// Chrome-level TLS dispatcher: impit (true JA3/JA4 BoringSSL match) with undici fallback
|
|
1
|
+
import { Readability } from "@mozilla/readability";
|
|
2
|
+
import TurndownService from "turndown";
|
|
3
|
+
import { parseHTML } from "linkedom";
|
|
4
|
+
import { RobotsCache } from "./robots-txt.js";
|
|
5
|
+
import { ResponseCache } from "./response-cache.js";
|
|
6
|
+
import { extractMetadata, extractTables, extractImages } from "./extractors/advanced.js";
|
|
7
|
+
import { CHROME_CIPHERS, CHROME_CURVES } from "./tls-fingerprint.js";
|
|
8
|
+
import { renderingPredictor } from "./engine-router.js";
|
|
30
9
|
let chromeFetchDispatcher;
|
|
31
10
|
async function getChromeFetchDispatcher() {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
catch { /* impit not installed — fall through to undici */ }
|
|
44
|
-
const { Agent } = await import('undici');
|
|
45
|
-
chromeFetchDispatcher = new Agent({
|
|
46
|
-
connect: {
|
|
47
|
-
ciphers: CHROME_CIPHERS,
|
|
48
|
-
ecdhCurve: CHROME_CURVES,
|
|
49
|
-
minVersion: 'TLSv1.2',
|
|
50
|
-
maxVersion: 'TLSv1.3',
|
|
51
|
-
},
|
|
52
|
-
keepAliveTimeout: 10_000,
|
|
53
|
-
keepAliveMaxTimeout: 30_000,
|
|
54
|
-
});
|
|
11
|
+
if (!chromeFetchDispatcher) {
|
|
12
|
+
try {
|
|
13
|
+
const impitMod = await import("impit");
|
|
14
|
+
const ImpitClass = impitMod.Impit ?? impitMod.default;
|
|
15
|
+
if (ImpitClass) {
|
|
16
|
+
chromeFetchDispatcher = new ImpitClass({ browser: "chrome" });
|
|
17
|
+
return chromeFetchDispatcher;
|
|
18
|
+
}
|
|
19
|
+
} catch {
|
|
55
20
|
}
|
|
56
|
-
|
|
21
|
+
const { Agent } = await import("undici");
|
|
22
|
+
chromeFetchDispatcher = new Agent({
|
|
23
|
+
connect: {
|
|
24
|
+
ciphers: CHROME_CIPHERS,
|
|
25
|
+
ecdhCurve: CHROME_CURVES,
|
|
26
|
+
minVersion: "TLSv1.2",
|
|
27
|
+
maxVersion: "TLSv1.3"
|
|
28
|
+
},
|
|
29
|
+
keepAliveTimeout: 1e4,
|
|
30
|
+
keepAliveMaxTimeout: 3e4
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
return chromeFetchDispatcher;
|
|
57
34
|
}
|
|
58
|
-
const turndown = new TurndownService({ headingStyle:
|
|
59
|
-
// Pages unlikely to have testable UI — skip from HTTP crawl queue
|
|
35
|
+
const turndown = new TurndownService({ headingStyle: "atx", codeBlockStyle: "fenced" });
|
|
60
36
|
const SKIP_PATH = /\/(terms|privacy|dpa|legal|cookie|changelog|blog|help|docs|about|contact|careers|press|imprint|gdpr|compliance|acceptable-use|security-policy|accessibility)([-/]|$)/i;
|
|
61
37
|
function canonicalize(url) {
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
u.searchParams.delete(p);
|
|
68
|
-
}
|
|
69
|
-
return u.href.toLowerCase().replace(/\/$/, '');
|
|
70
|
-
}
|
|
71
|
-
catch {
|
|
72
|
-
return url.toLowerCase();
|
|
38
|
+
try {
|
|
39
|
+
const u = new URL(url);
|
|
40
|
+
u.hash = "";
|
|
41
|
+
for (const p of ["utm_source", "utm_medium", "utm_campaign", "utm_content", "utm_term", "fbclid", "gclid", "ref", "referrer"]) {
|
|
42
|
+
u.searchParams.delete(p);
|
|
73
43
|
}
|
|
44
|
+
return u.href.toLowerCase().replace(/\/$/, "");
|
|
45
|
+
} catch {
|
|
46
|
+
return url.toLowerCase();
|
|
47
|
+
}
|
|
74
48
|
}
|
|
75
49
|
function extractLinks(html, baseUrl) {
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
if (!seen.has(c)) {
|
|
93
|
-
seen.add(c);
|
|
94
|
-
links.push(resolved);
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
catch { /* skip */ }
|
|
50
|
+
const links = [];
|
|
51
|
+
const seen = /* @__PURE__ */ new Set();
|
|
52
|
+
try {
|
|
53
|
+
const base = new URL(baseUrl);
|
|
54
|
+
const { document } = parseHTML(html);
|
|
55
|
+
const anchors = Array.from(document.querySelectorAll("a[href]"));
|
|
56
|
+
for (const a of anchors) {
|
|
57
|
+
const href = a.getAttribute("href") ?? "";
|
|
58
|
+
if (!href || href.startsWith("#") || href.startsWith("mailto:") || href.startsWith("tel:") || href.startsWith("javascript:")) continue;
|
|
59
|
+
try {
|
|
60
|
+
const resolved = new URL(href, base).href;
|
|
61
|
+
if (new URL(resolved).origin !== base.origin) continue;
|
|
62
|
+
const c = canonicalize(resolved);
|
|
63
|
+
if (!seen.has(c)) {
|
|
64
|
+
seen.add(c);
|
|
65
|
+
links.push(resolved);
|
|
98
66
|
}
|
|
67
|
+
} catch {
|
|
68
|
+
}
|
|
99
69
|
}
|
|
100
|
-
|
|
101
|
-
|
|
70
|
+
} catch {
|
|
71
|
+
}
|
|
72
|
+
return links;
|
|
102
73
|
}
|
|
103
74
|
function extractMarkdown(html, url) {
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
const md = turndown.turndown(article.content ?? html);
|
|
113
|
-
return { markdown: md, title: article.title ?? null };
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
catch { /* fall through */ }
|
|
117
|
-
// Full-page fallback
|
|
118
|
-
try {
|
|
119
|
-
const { document } = parseHTML(html);
|
|
120
|
-
for (const tag of Array.from(document.querySelectorAll('script, style, noscript, nav, footer')))
|
|
121
|
-
tag.remove();
|
|
122
|
-
const bodyHtml = document.body?.innerHTML ?? html;
|
|
123
|
-
const md = turndown.turndown(bodyHtml);
|
|
124
|
-
const titleEl = document.querySelector('title');
|
|
125
|
-
return { markdown: md, title: titleEl?.textContent?.trim() ?? null };
|
|
126
|
-
}
|
|
127
|
-
catch {
|
|
128
|
-
return { markdown: '', title: null };
|
|
75
|
+
try {
|
|
76
|
+
const { document } = parseHTML(html);
|
|
77
|
+
for (const tag of Array.from(document.querySelectorAll("script, style, noscript"))) tag.remove();
|
|
78
|
+
const reader = new Readability(document, { charThreshold: 50 });
|
|
79
|
+
const article = reader.parse();
|
|
80
|
+
if (article && article.textContent && article.textContent.split(/\s+/).length >= 30) {
|
|
81
|
+
const md = turndown.turndown(article.content ?? html);
|
|
82
|
+
return { markdown: md, title: article.title ?? null };
|
|
129
83
|
}
|
|
84
|
+
} catch {
|
|
85
|
+
}
|
|
86
|
+
try {
|
|
87
|
+
const { document } = parseHTML(html);
|
|
88
|
+
for (const tag of Array.from(document.querySelectorAll("script, style, noscript, nav, footer"))) tag.remove();
|
|
89
|
+
const bodyHtml = document.body?.innerHTML ?? html;
|
|
90
|
+
const md = turndown.turndown(bodyHtml);
|
|
91
|
+
const titleEl = document.querySelector("title");
|
|
92
|
+
return { markdown: md, title: titleEl?.textContent?.trim() ?? null };
|
|
93
|
+
} catch {
|
|
94
|
+
return { markdown: "", title: null };
|
|
95
|
+
}
|
|
130
96
|
}
|
|
131
97
|
async function fetchWithBackoff(url, cache, opts, retryCount = 0) {
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
98
|
+
const t0 = performance.now();
|
|
99
|
+
if (cache.isFresh(url)) {
|
|
100
|
+
const cached = cache.get(url);
|
|
101
|
+
return { body: cached.body, statusCode: 200, contentType: cached.contentType, fromCache: true, durationMs: 0 };
|
|
102
|
+
}
|
|
103
|
+
const validationHeaders = cache.getValidationHeaders(url);
|
|
104
|
+
const headers = {
|
|
105
|
+
"User-Agent": opts.userAgent,
|
|
106
|
+
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
|
|
107
|
+
"Accept-Language": "en-US,en;q=0.9",
|
|
108
|
+
"Accept-Encoding": "gzip, deflate, br, zstd",
|
|
109
|
+
// Chrome-like security headers reduce bot-detection fingerprint
|
|
110
|
+
"sec-ch-ua": '"Google Chrome";v="120", "Chromium";v="120", "Not-A.Brand";v="99"',
|
|
111
|
+
"sec-ch-ua-mobile": "?0",
|
|
112
|
+
"sec-ch-ua-platform": '"Windows"',
|
|
113
|
+
"Sec-Fetch-Dest": "document",
|
|
114
|
+
"Sec-Fetch-Mode": "navigate",
|
|
115
|
+
"Sec-Fetch-Site": "none",
|
|
116
|
+
"Sec-Fetch-User": "?1",
|
|
117
|
+
"Upgrade-Insecure-Requests": "1",
|
|
118
|
+
...validationHeaders,
|
|
119
|
+
...opts.extraHeaders ?? {}
|
|
120
|
+
};
|
|
121
|
+
let response;
|
|
122
|
+
try {
|
|
123
|
+
const dispatcher = await getChromeFetchDispatcher();
|
|
124
|
+
response = await fetch(url, {
|
|
125
|
+
headers,
|
|
126
|
+
signal: AbortSignal.timeout(opts.timeoutMs),
|
|
127
|
+
redirect: "follow",
|
|
128
|
+
dispatcher
|
|
129
|
+
});
|
|
130
|
+
} catch (e) {
|
|
131
|
+
throw new Error(`[http-engine] fetch failed for ${url}: ${e.message}`);
|
|
132
|
+
}
|
|
133
|
+
const durationMs = performance.now() - t0;
|
|
134
|
+
if (response.status === 304) {
|
|
135
|
+
const cached = cache.get(url);
|
|
136
|
+
if (cached) {
|
|
137
|
+
cache.markHit(url);
|
|
138
|
+
return { body: cached.body, statusCode: 200, contentType: cached.contentType, fromCache: true, durationMs };
|
|
136
139
|
}
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
'Upgrade-Insecure-Requests': '1',
|
|
152
|
-
...validationHeaders,
|
|
153
|
-
...(opts.extraHeaders ?? {}),
|
|
154
|
-
};
|
|
155
|
-
let response;
|
|
156
|
-
try {
|
|
157
|
-
const dispatcher = await getChromeFetchDispatcher();
|
|
158
|
-
response = await fetch(url, {
|
|
159
|
-
headers,
|
|
160
|
-
signal: AbortSignal.timeout(opts.timeoutMs),
|
|
161
|
-
redirect: 'follow',
|
|
162
|
-
dispatcher,
|
|
163
|
-
});
|
|
140
|
+
}
|
|
141
|
+
if (response.status === 429 || response.status === 503) {
|
|
142
|
+
if (retryCount >= 5) throw new Error(`[http-engine] rate-limited after 5 retries: ${url}`);
|
|
143
|
+
const retryAfter = parseInt(response.headers.get("retry-after") ?? "0") || 0;
|
|
144
|
+
const baseDelay = retryAfter > 0 ? retryAfter * 1e3 : Math.min(6e4, 1e3 * Math.pow(2, retryCount));
|
|
145
|
+
const jitter = Math.random() * 1e3;
|
|
146
|
+
console.log(`[http-engine] ${response.status} on ${url} \u2014 backing off ${Math.round(baseDelay + jitter)}ms (retry ${retryCount + 1}/5)`);
|
|
147
|
+
await new Promise((r) => setTimeout(r, baseDelay + jitter));
|
|
148
|
+
return fetchWithBackoff(url, cache, opts, retryCount + 1);
|
|
149
|
+
}
|
|
150
|
+
if (response.status >= 500) {
|
|
151
|
+
if (retryCount < 3) {
|
|
152
|
+
await new Promise((r) => setTimeout(r, 2e3 * (retryCount + 1)));
|
|
153
|
+
return fetchWithBackoff(url, cache, opts, retryCount + 1);
|
|
164
154
|
}
|
|
165
|
-
|
|
166
|
-
|
|
155
|
+
if (cache.staleIfError(url)) {
|
|
156
|
+
const cached = cache.get(url);
|
|
157
|
+
return { body: cached.body, statusCode: 200, contentType: cached.contentType, fromCache: true, durationMs: performance.now() - t0 };
|
|
167
158
|
}
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
if (response.status === 429 || response.status === 503) {
|
|
179
|
-
if (retryCount >= 5)
|
|
180
|
-
throw new Error(`[http-engine] rate-limited after 5 retries: ${url}`);
|
|
181
|
-
const retryAfter = parseInt(response.headers.get('retry-after') ?? '0') || 0;
|
|
182
|
-
const baseDelay = retryAfter > 0 ? retryAfter * 1000 : Math.min(60_000, 1_000 * Math.pow(2, retryCount));
|
|
183
|
-
const jitter = Math.random() * 1000;
|
|
184
|
-
console.log(`[http-engine] ${response.status} on ${url} — backing off ${Math.round(baseDelay + jitter)}ms (retry ${retryCount + 1}/5)`);
|
|
185
|
-
await new Promise(r => setTimeout(r, baseDelay + jitter));
|
|
186
|
-
return fetchWithBackoff(url, cache, opts, retryCount + 1);
|
|
187
|
-
}
|
|
188
|
-
// 5xx — retry up to 3 times
|
|
189
|
-
if (response.status >= 500) {
|
|
190
|
-
if (retryCount < 3) {
|
|
191
|
-
await new Promise(r => setTimeout(r, 2_000 * (retryCount + 1)));
|
|
192
|
-
return fetchWithBackoff(url, cache, opts, retryCount + 1);
|
|
193
|
-
}
|
|
194
|
-
if (cache.staleIfError(url)) {
|
|
195
|
-
const cached = cache.get(url);
|
|
196
|
-
return { body: cached.body, statusCode: 200, contentType: cached.contentType, fromCache: true, durationMs: performance.now() - t0 };
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
|
-
const contentType = response.headers.get('content-type') ?? '';
|
|
200
|
-
const body = await response.text();
|
|
201
|
-
const etag = response.headers.get('etag') ?? undefined;
|
|
202
|
-
const lastModified = response.headers.get('last-modified') ?? undefined;
|
|
203
|
-
const cacheControl = response.headers.get('cache-control') ?? undefined;
|
|
204
|
-
// Cache successful HTML responses
|
|
205
|
-
if (response.ok && contentType.includes('html')) {
|
|
206
|
-
cache.set(url, { url, etag, lastModified, cacheControl, body, contentType, statusCode: response.status });
|
|
207
|
-
}
|
|
208
|
-
return { body, statusCode: response.status, contentType, fromCache: false, durationMs, etag, lastModified };
|
|
159
|
+
}
|
|
160
|
+
const contentType = response.headers.get("content-type") ?? "";
|
|
161
|
+
const body = await response.text();
|
|
162
|
+
const etag = response.headers.get("etag") ?? void 0;
|
|
163
|
+
const lastModified = response.headers.get("last-modified") ?? void 0;
|
|
164
|
+
const cacheControl = response.headers.get("cache-control") ?? void 0;
|
|
165
|
+
if (response.ok && contentType.includes("html")) {
|
|
166
|
+
cache.set(url, { url, etag, lastModified, cacheControl, body, contentType, statusCode: response.status });
|
|
167
|
+
}
|
|
168
|
+
return { body, statusCode: response.status, contentType, fromCache: false, durationMs, etag, lastModified };
|
|
209
169
|
}
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
catch {
|
|
223
|
-
|
|
224
|
-
} })() : null;
|
|
225
|
-
const visited = new Set();
|
|
226
|
-
const queue = [opts.appUrl];
|
|
227
|
-
const queued = new Set([canonicalize(opts.appUrl)]);
|
|
228
|
-
const screens = [];
|
|
229
|
-
let cachedCount = 0;
|
|
230
|
-
let errorCount = 0;
|
|
231
|
-
let rateLimitedCount = 0;
|
|
232
|
-
const batchFetch = async (urls) => {
|
|
233
|
-
await Promise.all(urls.map(async (url) => {
|
|
234
|
-
const canonUrl = canonicalize(url);
|
|
235
|
-
if (visited.has(canonUrl))
|
|
236
|
-
return;
|
|
237
|
-
visited.add(canonUrl);
|
|
238
|
-
// Robots.txt check
|
|
239
|
-
if (robots && !(await robots.isAllowed(url).catch(() => true))) {
|
|
240
|
-
console.log(`[http-engine] robots.txt blocked: ${url}`);
|
|
241
|
-
return;
|
|
242
|
-
}
|
|
243
|
-
// Skip content/legal pages
|
|
244
|
-
try {
|
|
245
|
-
const { pathname } = new URL(url);
|
|
246
|
-
if (SKIP_PATH.test(pathname))
|
|
247
|
-
return;
|
|
248
|
-
}
|
|
249
|
-
catch { /* skip */ }
|
|
250
|
-
// URL filter
|
|
251
|
-
if (urlFilterRe) {
|
|
252
|
-
try {
|
|
253
|
-
const { pathname, search } = new URL(url);
|
|
254
|
-
if (!urlFilterRe.test(pathname + search)) {
|
|
255
|
-
console.log(`[http-engine] filtered out: ${url}`);
|
|
256
|
-
return;
|
|
257
|
-
}
|
|
258
|
-
}
|
|
259
|
-
catch { /* skip */ }
|
|
260
|
-
}
|
|
261
|
-
let fetchResult;
|
|
262
|
-
try {
|
|
263
|
-
fetchResult = await fetchWithBackoff(url, cache, { userAgent, timeoutMs, extraHeaders: opts.headers });
|
|
264
|
-
if (fetchResult.statusCode === 429 || fetchResult.statusCode === 503)
|
|
265
|
-
rateLimitedCount++;
|
|
266
|
-
}
|
|
267
|
-
catch (err) {
|
|
268
|
-
console.error(`[http-engine] error fetching ${url}: ${err.message}`);
|
|
269
|
-
errorCount++;
|
|
270
|
-
return;
|
|
271
|
-
}
|
|
272
|
-
if (!fetchResult.contentType.includes('html'))
|
|
273
|
-
return; // skip non-HTML
|
|
274
|
-
if (fetchResult.statusCode >= 400)
|
|
275
|
-
return;
|
|
276
|
-
if (fetchResult.fromCache)
|
|
277
|
-
cachedCount++;
|
|
278
|
-
const { markdown, title } = extractMarkdown(fetchResult.body, url);
|
|
279
|
-
const fullMeta = extractMetadata(fetchResult.body, url);
|
|
280
|
-
const links = extractLinks(fetchResult.body, url);
|
|
281
|
-
const tables = extractTables(fetchResult.body);
|
|
282
|
-
const images = extractImages(fetchResult.body, url);
|
|
283
|
-
const screen = {
|
|
284
|
-
url,
|
|
285
|
-
title: title ?? fullMeta.title,
|
|
286
|
-
markdown,
|
|
287
|
-
html: opts.includeRawHtml ? fetchResult.body : undefined,
|
|
288
|
-
metadata: {
|
|
289
|
-
description: fullMeta.description,
|
|
290
|
-
ogTitle: fullMeta.ogTitle,
|
|
291
|
-
ogDescription: fullMeta.ogDescription,
|
|
292
|
-
ogImage: fullMeta.ogImage,
|
|
293
|
-
canonical: fullMeta.canonical,
|
|
294
|
-
schemaTypes: fullMeta.schemaTypes,
|
|
295
|
-
lang: fullMeta.lang,
|
|
296
|
-
},
|
|
297
|
-
tables,
|
|
298
|
-
links,
|
|
299
|
-
images: images.map(i => ({ src: i.src, alt: i.alt })),
|
|
300
|
-
statusCode: fetchResult.statusCode,
|
|
301
|
-
fromCache: fetchResult.fromCache,
|
|
302
|
-
requiresBrowser: renderingPredictor.getCached(url) === 'browser',
|
|
303
|
-
durationMs: fetchResult.durationMs,
|
|
304
|
-
};
|
|
305
|
-
screens.push(screen);
|
|
306
|
-
// Record rendering prediction: SPA signals → browser needed; content-rich → http engine works
|
|
307
|
-
renderingPredictor.predict(url, fetchResult.body, fetchResult.statusCode);
|
|
308
|
-
opts.onProgress?.({ url, screensFound: screens.length, total: maxScreens, cached: fetchResult.fromCache });
|
|
309
|
-
// Enqueue discovered links
|
|
310
|
-
for (const link of links) {
|
|
311
|
-
const c = canonicalize(link);
|
|
312
|
-
if (!queued.has(c) && !visited.has(c) && screens.length + queue.length < maxScreens * 2) {
|
|
313
|
-
queued.add(c);
|
|
314
|
-
if (opts.traversalStrategy === 'DFS')
|
|
315
|
-
queue.unshift(link);
|
|
316
|
-
else
|
|
317
|
-
queue.push(link);
|
|
318
|
-
}
|
|
319
|
-
}
|
|
320
|
-
}));
|
|
321
|
-
};
|
|
322
|
-
const concurrency = opts.concurrency ?? 5;
|
|
323
|
-
while (queue.length > 0 && screens.length < maxScreens) {
|
|
324
|
-
const batch = queue.splice(0, concurrency);
|
|
325
|
-
await batchFetch(batch);
|
|
326
|
-
}
|
|
327
|
-
// Output formats
|
|
328
|
-
let jsonLines;
|
|
329
|
-
let csv;
|
|
330
|
-
const formats = opts.outputFormats ?? [];
|
|
331
|
-
if (formats.includes('jsonlines')) {
|
|
332
|
-
jsonLines = screens.map(s => JSON.stringify({ url: s.url, title: s.title, markdown: s.markdown, metadata: s.metadata })).join('\n');
|
|
333
|
-
}
|
|
334
|
-
if (formats.includes('csv')) {
|
|
335
|
-
const header = 'url,title,description,ogTitle,schemaTypes,statusCode,fromCache,durationMs';
|
|
336
|
-
const rows = screens.map(s => [s.url, s.title ?? '', s.metadata.description ?? '', s.metadata.ogTitle ?? '', s.metadata.schemaTypes.join('|'), s.statusCode, s.fromCache, Math.round(s.durationMs)].map(v => `"${String(v).replace(/"/g, '""')}"`).join(','));
|
|
337
|
-
csv = [header, ...rows].join('\n');
|
|
170
|
+
async function httpCrawl(opts) {
|
|
171
|
+
const startTime = performance.now();
|
|
172
|
+
const maxScreens = opts.maxScreens ?? 100;
|
|
173
|
+
const cacheDir = opts.cacheDir ?? process.env.CRAWLER_CACHE_DIR ?? "/tmp/zeta-cache";
|
|
174
|
+
const timeoutMs = opts.timeoutMs ?? 15e3;
|
|
175
|
+
const CRAWLER_USER_AGENT = process.env.CRAWLER_USER_AGENT ?? "ZeTa-Crawler/1.0 (+https://zetaai.io/crawler)";
|
|
176
|
+
const userAgent = opts.userAgent ?? CRAWLER_USER_AGENT;
|
|
177
|
+
const cache = new ResponseCache(cacheDir);
|
|
178
|
+
const robots = opts.respectRobotsTxt !== false ? new RobotsCache() : null;
|
|
179
|
+
const urlFilterRe = opts.urlFilter ? (() => {
|
|
180
|
+
try {
|
|
181
|
+
return new RegExp(opts.urlFilter, "i");
|
|
182
|
+
} catch {
|
|
183
|
+
return null;
|
|
338
184
|
}
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
185
|
+
})() : null;
|
|
186
|
+
const visited = /* @__PURE__ */ new Set();
|
|
187
|
+
const queue = [opts.appUrl];
|
|
188
|
+
const queued = /* @__PURE__ */ new Set([canonicalize(opts.appUrl)]);
|
|
189
|
+
const screens = [];
|
|
190
|
+
let cachedCount = 0;
|
|
191
|
+
let errorCount = 0;
|
|
192
|
+
let rateLimitedCount = 0;
|
|
193
|
+
const batchFetch = async (urls) => {
|
|
194
|
+
await Promise.all(urls.map(async (url) => {
|
|
195
|
+
const canonUrl = canonicalize(url);
|
|
196
|
+
if (visited.has(canonUrl)) return;
|
|
197
|
+
visited.add(canonUrl);
|
|
198
|
+
if (robots && !await robots.isAllowed(url).catch(() => true)) {
|
|
199
|
+
console.log(`[http-engine] robots.txt blocked: ${url}`);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
try {
|
|
203
|
+
const { pathname } = new URL(url);
|
|
204
|
+
if (SKIP_PATH.test(pathname)) return;
|
|
205
|
+
} catch {
|
|
206
|
+
}
|
|
207
|
+
if (urlFilterRe) {
|
|
208
|
+
try {
|
|
209
|
+
const { pathname, search } = new URL(url);
|
|
210
|
+
if (!urlFilterRe.test(pathname + search)) {
|
|
211
|
+
console.log(`[http-engine] filtered out: ${url}`);
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
} catch {
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
let fetchResult;
|
|
218
|
+
try {
|
|
219
|
+
fetchResult = await fetchWithBackoff(url, cache, { userAgent, timeoutMs, extraHeaders: opts.headers });
|
|
220
|
+
if (fetchResult.statusCode === 429 || fetchResult.statusCode === 503) rateLimitedCount++;
|
|
221
|
+
} catch (err) {
|
|
222
|
+
console.error(`[http-engine] error fetching ${url}: ${err.message}`);
|
|
223
|
+
errorCount++;
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
if (!fetchResult.contentType.includes("html")) return;
|
|
227
|
+
if (fetchResult.statusCode >= 400) return;
|
|
228
|
+
if (fetchResult.fromCache) cachedCount++;
|
|
229
|
+
const { markdown, title } = extractMarkdown(fetchResult.body, url);
|
|
230
|
+
const fullMeta = extractMetadata(fetchResult.body, url);
|
|
231
|
+
const links = extractLinks(fetchResult.body, url);
|
|
232
|
+
const tables = extractTables(fetchResult.body);
|
|
233
|
+
const images = extractImages(fetchResult.body, url);
|
|
234
|
+
const screen = {
|
|
235
|
+
url,
|
|
236
|
+
title: title ?? fullMeta.title,
|
|
237
|
+
markdown,
|
|
238
|
+
html: opts.includeRawHtml ? fetchResult.body : void 0,
|
|
239
|
+
metadata: {
|
|
240
|
+
description: fullMeta.description,
|
|
241
|
+
ogTitle: fullMeta.ogTitle,
|
|
242
|
+
ogDescription: fullMeta.ogDescription,
|
|
243
|
+
ogImage: fullMeta.ogImage,
|
|
244
|
+
canonical: fullMeta.canonical,
|
|
245
|
+
schemaTypes: fullMeta.schemaTypes,
|
|
246
|
+
lang: fullMeta.lang
|
|
349
247
|
},
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
248
|
+
tables,
|
|
249
|
+
links,
|
|
250
|
+
images: images.map((i) => ({ src: i.src, alt: i.alt })),
|
|
251
|
+
statusCode: fetchResult.statusCode,
|
|
252
|
+
fromCache: fetchResult.fromCache,
|
|
253
|
+
requiresBrowser: renderingPredictor.getCached(url) === "browser",
|
|
254
|
+
durationMs: fetchResult.durationMs
|
|
255
|
+
};
|
|
256
|
+
screens.push(screen);
|
|
257
|
+
renderingPredictor.predict(url, fetchResult.body, fetchResult.statusCode);
|
|
258
|
+
opts.onProgress?.({ url, screensFound: screens.length, total: maxScreens, cached: fetchResult.fromCache });
|
|
259
|
+
for (const link of links) {
|
|
260
|
+
const c = canonicalize(link);
|
|
261
|
+
if (!queued.has(c) && !visited.has(c) && screens.length + queue.length < maxScreens * 2) {
|
|
262
|
+
queued.add(c);
|
|
263
|
+
if (opts.traversalStrategy === "DFS") queue.unshift(link);
|
|
264
|
+
else queue.push(link);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}));
|
|
268
|
+
};
|
|
269
|
+
const concurrency = opts.concurrency ?? 5;
|
|
270
|
+
while (queue.length > 0 && screens.length < maxScreens) {
|
|
271
|
+
const batch = queue.splice(0, concurrency);
|
|
272
|
+
await batchFetch(batch);
|
|
273
|
+
}
|
|
274
|
+
let jsonLines;
|
|
275
|
+
let csv;
|
|
276
|
+
const formats = opts.outputFormats ?? [];
|
|
277
|
+
if (formats.includes("jsonlines")) {
|
|
278
|
+
jsonLines = screens.map((s) => JSON.stringify({ url: s.url, title: s.title, markdown: s.markdown, metadata: s.metadata })).join("\n");
|
|
279
|
+
}
|
|
280
|
+
if (formats.includes("csv")) {
|
|
281
|
+
const header = "url,title,description,ogTitle,schemaTypes,statusCode,fromCache,durationMs";
|
|
282
|
+
const rows = screens.map(
|
|
283
|
+
(s) => [s.url, s.title ?? "", s.metadata.description ?? "", s.metadata.ogTitle ?? "", s.metadata.schemaTypes.join("|"), s.statusCode, s.fromCache, Math.round(s.durationMs)].map((v) => `"${String(v).replace(/"/g, '""')}"`).join(",")
|
|
284
|
+
);
|
|
285
|
+
csv = [header, ...rows].join("\n");
|
|
286
|
+
}
|
|
287
|
+
const cacheStats = cache.stats();
|
|
288
|
+
return {
|
|
289
|
+
screens,
|
|
290
|
+
stats: {
|
|
291
|
+
total: screens.length,
|
|
292
|
+
cached: cachedCount,
|
|
293
|
+
errors: errorCount,
|
|
294
|
+
rateLimited: rateLimitedCount,
|
|
295
|
+
durationMs: performance.now() - startTime,
|
|
296
|
+
cacheHitRate: cacheStats.hitRate
|
|
297
|
+
},
|
|
298
|
+
jsonLines,
|
|
299
|
+
csv
|
|
300
|
+
};
|
|
353
301
|
}
|
|
302
|
+
export {
|
|
303
|
+
httpCrawl
|
|
304
|
+
};
|
|
305
|
+
//# sourceMappingURL=http-engine.js.map
|