@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.
Files changed (83) hide show
  1. package/dist/accessibility-auditor.js +54 -74
  2. package/dist/ai-login.js +227 -249
  3. package/dist/auth-token-rotator.js +57 -67
  4. package/dist/auth.js +299 -336
  5. package/dist/browser-extension.js +104 -142
  6. package/dist/captcha-solver.js +98 -125
  7. package/dist/color-contrast.js +77 -89
  8. package/dist/content-chunker.js +126 -126
  9. package/dist/content-dedup.js +54 -71
  10. package/dist/cookie-consent.js +68 -83
  11. package/dist/cookie-jar.js +53 -60
  12. package/dist/crawler.js +4494 -5353
  13. package/dist/custom-js-injector.js +45 -61
  14. package/dist/dark-mode-capture.js +67 -81
  15. package/dist/dead-link-detector.js +47 -55
  16. package/dist/element-hash.js +8 -12
  17. package/dist/email-otp.js +154 -195
  18. package/dist/engine-router.js +87 -98
  19. package/dist/export/bulk-export.js +70 -55
  20. package/dist/export/vector-export.js +77 -105
  21. package/dist/extractors/advanced.js +249 -266
  22. package/dist/extractors/artifact-envelope.js +59 -58
  23. package/dist/failure-analysis-agent.js +37 -33
  24. package/dist/feature-flag-detector.js +51 -66
  25. package/dist/form-detector.js +55 -75
  26. package/dist/frontier.js +76 -101
  27. package/dist/gap-pass.js +68 -89
  28. package/dist/graphql-detector.js +62 -72
  29. package/dist/http-engine.js +282 -330
  30. package/dist/incremental-diff.js +54 -87
  31. package/dist/index.js +67 -28
  32. package/dist/input-fuzzer.js +93 -97
  33. package/dist/interactive-probe.js +715 -827
  34. package/dist/job-queue.js +2151 -2169
  35. package/dist/js-coverage.js +52 -56
  36. package/dist/js-router-detector.js +86 -117
  37. package/dist/language-detector.js +51 -63
  38. package/dist/link-classifier.js +32 -50
  39. package/dist/live-view.js +91 -108
  40. package/dist/locator-extractor.js +151 -187
  41. package/dist/logger.js +9 -7
  42. package/dist/markdown-extract.js +48 -61
  43. package/dist/native-crawler.js +780 -946
  44. package/dist/network-observer.js +135 -156
  45. package/dist/network-throttle.js +29 -40
  46. package/dist/openapi-generator.js +77 -92
  47. package/dist/pdf-extractor.js +72 -90
  48. package/dist/performance-metrics.js +80 -102
  49. package/dist/phash.js +125 -144
  50. package/dist/pii-scrubber.js +65 -68
  51. package/dist/playwright-recorder.js +70 -58
  52. package/dist/policies/crawl-safety.js +31 -34
  53. package/dist/profiles/domain-profile.js +130 -125
  54. package/dist/proxy.js +146 -172
  55. package/dist/rate-limiter.js +58 -65
  56. package/dist/redirect-tracker.js +60 -64
  57. package/dist/redis-frontier.js +116 -136
  58. package/dist/redis-queue.js +157 -144
  59. package/dist/response-cache.js +127 -140
  60. package/dist/responsive-detector.js +42 -46
  61. package/dist/retry-policy.js +46 -50
  62. package/dist/robots-txt.js +69 -83
  63. package/dist/screen-reader.js +45 -70
  64. package/dist/screenshot-masker.js +59 -77
  65. package/dist/seo-checker.js +88 -107
  66. package/dist/session-capture.js +192 -212
  67. package/dist/session-export.js +107 -133
  68. package/dist/sitemap-writer.js +38 -41
  69. package/dist/sitemap.js +141 -202
  70. package/dist/skyvern.js +123 -148
  71. package/dist/sqs-queue.js +42 -42
  72. package/dist/sso-token.js +74 -90
  73. package/dist/stealth/human-behavior.js +156 -173
  74. package/dist/telemetry.js +68 -82
  75. package/dist/test-executor.js +309 -335
  76. package/dist/tls-fingerprint.js +77 -88
  77. package/dist/url-pattern-completer.js +71 -90
  78. package/dist/visual-diff-verdict.js +23 -38
  79. package/dist/webhook-notifier.js +71 -69
  80. package/dist/websocket-detector.js +56 -68
  81. package/dist/worker.js +29 -38
  82. package/package.json +1 -1
  83. package/src/job-queue.ts +136 -0
@@ -1,353 +1,305 @@
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
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
- 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
- });
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
- return chromeFetchDispatcher;
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: 'atx', codeBlockStyle: 'fenced' });
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
- 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();
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
- 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 */ }
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
- catch { /* non-fatal */ }
101
- return links;
70
+ } catch {
71
+ }
72
+ return links;
102
73
  }
103
74
  function extractMarkdown(html, url) {
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 };
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
- 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 };
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
- 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
- });
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
- catch (e) {
166
- throw new Error(`[http-engine] fetch failed for ${url}: ${e.message}`);
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
- 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
- }
176
- }
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);
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
- 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');
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
- 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,
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
- jsonLines,
351
- csv,
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