@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.
Files changed (82) hide show
  1. package/dist/accessibility-auditor.js +74 -54
  2. package/dist/ai-login.js +249 -227
  3. package/dist/auth-token-rotator.js +67 -57
  4. package/dist/auth.js +336 -299
  5. package/dist/browser-extension.js +142 -104
  6. package/dist/captcha-solver.js +125 -98
  7. package/dist/color-contrast.js +89 -77
  8. package/dist/content-chunker.js +126 -126
  9. package/dist/content-dedup.js +71 -54
  10. package/dist/cookie-consent.js +83 -68
  11. package/dist/cookie-jar.js +60 -53
  12. package/dist/crawler.js +5345 -4486
  13. package/dist/custom-js-injector.js +61 -45
  14. package/dist/dark-mode-capture.js +81 -67
  15. package/dist/dead-link-detector.js +55 -47
  16. package/dist/element-hash.js +12 -8
  17. package/dist/email-otp.js +195 -154
  18. package/dist/engine-router.js +98 -87
  19. package/dist/export/bulk-export.js +55 -70
  20. package/dist/export/vector-export.js +105 -77
  21. package/dist/extractors/advanced.js +266 -249
  22. package/dist/extractors/artifact-envelope.js +58 -59
  23. package/dist/failure-analysis-agent.js +33 -37
  24. package/dist/feature-flag-detector.js +66 -51
  25. package/dist/form-detector.js +75 -55
  26. package/dist/frontier.js +101 -76
  27. package/dist/gap-pass.js +89 -68
  28. package/dist/graphql-detector.js +72 -62
  29. package/dist/http-engine.js +330 -282
  30. package/dist/incremental-diff.js +87 -54
  31. package/dist/index.js +28 -67
  32. package/dist/input-fuzzer.js +97 -93
  33. package/dist/interactive-probe.js +827 -715
  34. package/dist/job-queue.js +2169 -2151
  35. package/dist/js-coverage.js +56 -52
  36. package/dist/js-router-detector.js +117 -86
  37. package/dist/language-detector.js +63 -51
  38. package/dist/link-classifier.js +50 -32
  39. package/dist/live-view.js +108 -91
  40. package/dist/locator-extractor.js +187 -151
  41. package/dist/logger.js +7 -9
  42. package/dist/markdown-extract.js +61 -48
  43. package/dist/native-crawler.js +946 -780
  44. package/dist/network-observer.js +156 -135
  45. package/dist/network-throttle.js +40 -29
  46. package/dist/openapi-generator.js +92 -77
  47. package/dist/pdf-extractor.js +90 -72
  48. package/dist/performance-metrics.js +102 -80
  49. package/dist/phash.js +144 -125
  50. package/dist/pii-scrubber.js +68 -65
  51. package/dist/playwright-recorder.js +58 -70
  52. package/dist/policies/crawl-safety.js +34 -31
  53. package/dist/profiles/domain-profile.js +125 -130
  54. package/dist/proxy.js +172 -146
  55. package/dist/rate-limiter.js +65 -58
  56. package/dist/redirect-tracker.js +64 -60
  57. package/dist/redis-frontier.js +136 -116
  58. package/dist/redis-queue.js +144 -157
  59. package/dist/response-cache.js +140 -127
  60. package/dist/responsive-detector.js +46 -42
  61. package/dist/retry-policy.js +50 -46
  62. package/dist/robots-txt.js +83 -69
  63. package/dist/screen-reader.js +70 -45
  64. package/dist/screenshot-masker.js +77 -59
  65. package/dist/seo-checker.js +107 -88
  66. package/dist/session-capture.js +212 -192
  67. package/dist/session-export.js +133 -107
  68. package/dist/sitemap-writer.js +41 -38
  69. package/dist/sitemap.js +202 -141
  70. package/dist/skyvern.js +148 -123
  71. package/dist/sqs-queue.js +42 -42
  72. package/dist/sso-token.js +90 -74
  73. package/dist/stealth/human-behavior.js +173 -156
  74. package/dist/telemetry.js +82 -68
  75. package/dist/test-executor.js +335 -309
  76. package/dist/tls-fingerprint.js +88 -77
  77. package/dist/url-pattern-completer.js +90 -71
  78. package/dist/visual-diff-verdict.js +38 -23
  79. package/dist/webhook-notifier.js +69 -71
  80. package/dist/websocket-detector.js +68 -56
  81. package/dist/worker.js +38 -29
  82. package/package.json +1 -1
@@ -1,305 +1,353 @@
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";
1
+ /**
2
+ * HTTP-only crawl engine — 10-50x faster than browser for SSR/static sites.
3
+ *
4
+ * Uses native Node.js fetch (HTTP/2), linkedom, Readability, and Turndown.
5
+ * No browser overhead. Falls back to browser engine on JS-heavy sites.
6
+ *
7
+ * Features:
8
+ * - HTTP/2 native (Node 20 built-in fetch)
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
9
30
  let chromeFetchDispatcher;
10
31
  async function getChromeFetchDispatcher() {
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 {
32
+ if (!chromeFetchDispatcher) {
33
+ try {
34
+ // impit provides native Chrome BoringSSL TLS fingerprint — true JA3/JA4 match
35
+ // @ts-ignore — impit is an optional dependency
36
+ const impitMod = await import('impit');
37
+ const ImpitClass = impitMod.Impit ?? impitMod.default;
38
+ if (ImpitClass) {
39
+ chromeFetchDispatcher = new ImpitClass({ browser: 'chrome' });
40
+ return chromeFetchDispatcher;
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
+ });
20
55
  }
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;
56
+ return chromeFetchDispatcher;
34
57
  }
35
- const turndown = new TurndownService({ headingStyle: "atx", codeBlockStyle: "fenced" });
58
+ const turndown = new TurndownService({ headingStyle: 'atx', codeBlockStyle: 'fenced' });
59
+ // Pages unlikely to have testable UI — skip from HTTP crawl queue
36
60
  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;
37
61
  function canonicalize(url) {
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);
62
+ try {
63
+ const u = new URL(url);
64
+ u.hash = '';
65
+ // Strip tracking params
66
+ for (const p of ['utm_source', 'utm_medium', 'utm_campaign', 'utm_content', 'utm_term', 'fbclid', 'gclid', 'ref', 'referrer']) {
67
+ u.searchParams.delete(p);
68
+ }
69
+ return u.href.toLowerCase().replace(/\/$/, '');
70
+ }
71
+ catch {
72
+ return url.toLowerCase();
43
73
  }
44
- return u.href.toLowerCase().replace(/\/$/, "");
45
- } catch {
46
- return url.toLowerCase();
47
- }
48
74
  }
49
75
  function extractLinks(html, baseUrl) {
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);
76
+ const links = [];
77
+ const seen = new Set();
78
+ try {
79
+ const base = new URL(baseUrl);
80
+ const { document } = parseHTML(html);
81
+ const anchors = Array.from(document.querySelectorAll('a[href]'));
82
+ for (const a of anchors) {
83
+ const href = a.getAttribute('href') ?? '';
84
+ if (!href || href.startsWith('#') || href.startsWith('mailto:') || href.startsWith('tel:') || href.startsWith('javascript:'))
85
+ continue;
86
+ try {
87
+ const resolved = new URL(href, base).href;
88
+ // Same origin only
89
+ if (new URL(resolved).origin !== base.origin)
90
+ continue;
91
+ const c = canonicalize(resolved);
92
+ if (!seen.has(c)) {
93
+ seen.add(c);
94
+ links.push(resolved);
95
+ }
96
+ }
97
+ catch { /* skip */ }
66
98
  }
67
- } catch {
68
- }
69
99
  }
70
- } catch {
71
- }
72
- return links;
100
+ catch { /* non-fatal */ }
101
+ return links;
73
102
  }
74
103
  function extractMarkdown(html, url) {
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 };
104
+ try {
105
+ const { document } = parseHTML(html);
106
+ // Remove script/style tags before Readability
107
+ for (const tag of Array.from(document.querySelectorAll('script, style, noscript')))
108
+ tag.remove();
109
+ const reader = new Readability(document, { charThreshold: 50 });
110
+ const article = reader.parse();
111
+ if (article && article.textContent && article.textContent.split(/\s+/).length >= 30) {
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 };
83
129
  }
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
- }
96
130
  }
97
131
  async function fetchWithBackoff(url, cache, opts, retryCount = 0) {
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 };
132
+ const t0 = performance.now();
133
+ if (cache.isFresh(url)) {
134
+ const cached = cache.get(url);
135
+ return { body: cached.body, statusCode: 200, contentType: cached.contentType, fromCache: true, durationMs: 0 };
139
136
  }
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);
137
+ const validationHeaders = cache.getValidationHeaders(url);
138
+ const headers = {
139
+ 'User-Agent': opts.userAgent,
140
+ 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8',
141
+ 'Accept-Language': 'en-US,en;q=0.9',
142
+ 'Accept-Encoding': 'gzip, deflate, br, zstd',
143
+ // Chrome-like security headers reduce bot-detection fingerprint
144
+ 'sec-ch-ua': '"Google Chrome";v="120", "Chromium";v="120", "Not-A.Brand";v="99"',
145
+ 'sec-ch-ua-mobile': '?0',
146
+ 'sec-ch-ua-platform': '"Windows"',
147
+ 'Sec-Fetch-Dest': 'document',
148
+ 'Sec-Fetch-Mode': 'navigate',
149
+ 'Sec-Fetch-Site': 'none',
150
+ 'Sec-Fetch-User': '?1',
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
+ });
154
164
  }
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 };
165
+ catch (e) {
166
+ throw new Error(`[http-engine] fetch failed for ${url}: ${e.message}`);
158
167
  }
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 };
169
- }
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;
168
+ const durationMs = performance.now() - t0;
169
+ // 304 Not Modified — use cached body
170
+ if (response.status === 304) {
171
+ const cached = cache.get(url);
172
+ if (cached) {
173
+ cache.markHit(url);
174
+ return { body: cached.body, statusCode: 200, contentType: cached.contentType, fromCache: true, durationMs };
175
+ }
184
176
  }
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 {
177
+ // 429 / 503 — exponential backoff with jitter
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);
215
193
  }
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
247
- },
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);
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 };
265
197
  }
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
- };
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 };
209
+ }
210
+ export async function httpCrawl(opts) {
211
+ const startTime = performance.now();
212
+ const maxScreens = opts.maxScreens ?? 100;
213
+ const cacheDir = opts.cacheDir ?? process.env.CRAWLER_CACHE_DIR ?? '/tmp/zeta-cache';
214
+ const timeoutMs = opts.timeoutMs ?? 15_000;
215
+ const CRAWLER_USER_AGENT = process.env.CRAWLER_USER_AGENT ?? 'ZeTa-Crawler/1.0 (+https://zetaai.io/crawler)';
216
+ const userAgent = opts.userAgent ?? CRAWLER_USER_AGENT;
217
+ const cache = new ResponseCache(cacheDir);
218
+ const robots = opts.respectRobotsTxt !== false ? new RobotsCache() : null;
219
+ const urlFilterRe = opts.urlFilter ? (() => { try {
220
+ return new RegExp(opts.urlFilter, 'i');
221
+ }
222
+ catch {
223
+ return null;
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');
338
+ }
339
+ const cacheStats = cache.stats();
340
+ return {
341
+ screens,
342
+ stats: {
343
+ total: screens.length,
344
+ cached: cachedCount,
345
+ errors: errorCount,
346
+ rateLimited: rateLimitedCount,
347
+ durationMs: performance.now() - startTime,
348
+ cacheHitRate: cacheStats.hitRate,
349
+ },
350
+ jsonLines,
351
+ csv,
352
+ };
301
353
  }
302
- export {
303
- httpCrawl
304
- };
305
- //# sourceMappingURL=http-engine.js.map