webcrawl 1.2.9 → 1.3.1

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.
@@ -17,7 +17,7 @@ export interface ScrapeResult {
17
17
  links?: string[];
18
18
  statusCode: number;
19
19
  contentHash: string;
20
- tier: "cache" | "static" | "sandbox" | "cloudflare" | "browser";
20
+ tier: "cache" | "static" | "sandbox" | "cloudflare" | "browser" | "impersonate";
21
21
  latencyMs: number;
22
22
  error?: string;
23
23
  }
@@ -37,8 +37,10 @@ export declare function setDomainUserAgent(hostname: string, userAgent: string):
37
37
  */
38
38
  export declare function isAwsWafChallenge(html: string, status?: number): boolean;
39
39
  export declare function isAntiBotChallenge(html: string, status: number): boolean;
40
+ /** Transport tier that won the last fetch ladder run (for result evidence). */
41
+ export declare function lastFetchTier(): "impit-tls" | "impersonate" | "fetch";
40
42
  /**
41
- * Fetches a URL using `impit` (BoringSSL Chrome 136 TLS JA3/JA4 + HTTP/2 fingerprint impersonation)
43
+ * Fetches a URL using `impit` (BoringSSL Chrome 136 TLS JA3/JA4 + HTTP/2 fingerprint impersonation)sonation)
42
44
  * so Cloudflare Bot Management and Vercel Security Checkpoint treat requests as real Chrome.
43
45
  * Falls back to standard `fetch` if `impit` is unavailable on the current platform.
44
46
  * Every redirect hop is checked against the SSRF guard.
@@ -104,11 +104,27 @@ export function isAntiBotChallenge(html, status) {
104
104
  lower.includes("_cf_chl_opt")) {
105
105
  return true;
106
106
  }
107
+ // IP-reputation interstitials (stubhub, expedia) that pass the TLS gate
108
+ // but gate datacenter IPs behind a "enable JS / are you human" shell.
109
+ // These can arrive cached or served with status 200, so text markers are
110
+ // required on top of the 403/429 status check above.
111
+ if (lower.includes("enable javascript and disable any ad blocker") ||
112
+ lower.includes("show us your human side") ||
113
+ lower.includes("we can't tell if you're a human or a bot") ||
114
+ lower.includes("we can’t tell if you’re a human or a bot")) {
115
+ return true;
116
+ }
107
117
  // AWS WAF serves its invisible JS challenge as HTTP 202 with none of the
108
118
  // Cloudflare/Vercel markers, so those pages used to pass as real content.
109
119
  return isAwsWafChallenge(html, status);
110
120
  }
111
121
  let sharedImpit = null;
122
+ /** Tier that won the most recent rawGet ladder ("impit-tls" → "impersonate" → "fetch"). */
123
+ let lastTier = "impit-tls";
124
+ /** Transport tier that won the last fetch ladder run (for result evidence). */
125
+ export function lastFetchTier() {
126
+ return lastTier;
127
+ }
112
128
  /** One request, redirects NOT followed (callers validate each hop via followSafely). */
113
129
  async function rawGet(url, reqHeaders, timeoutMs, maxBytes) {
114
130
  const read = async (status, headers, getBuf) => {
@@ -132,21 +148,92 @@ async function rawGet(url, reqHeaders, timeoutMs, maxBytes) {
132
148
  timeout: timeoutMs,
133
149
  redirect: "manual",
134
150
  });
135
- return await read(res.status, headersToObject(res.headers), () => res.arrayBuffer());
151
+ const hop = await read(res.status, headersToObject(res.headers), () => res.arrayBuffer());
152
+ // AWS-WAF-style blocks (403/429/challenge shells) on the impit tier
153
+ // (Chrome 136) retry once on the curl-impersonate tier (Chrome 150),
154
+ // which passes WAFs that block the older fingerprint.
155
+ if (isAntiBotChallenge(new TextDecoder().decode(hop.body).slice(0, 8000), hop.status)) {
156
+ const impersonateHop = await rawGetImpersonate(url, reqHeaders, timeoutMs, maxBytes);
157
+ if (impersonateHop) {
158
+ lastTier = "impersonate";
159
+ return impersonateHop;
160
+ }
161
+ }
162
+ lastTier = "impit-tls";
163
+ return hop;
136
164
  }
137
165
  catch (err) {
138
166
  if (err instanceof ResourceTooLargeError || err instanceof UnsafeUrlError)
139
167
  throw err;
168
+ // impit unavailable/failed: curl-impersonate tier, then plain fetch.
169
+ const impersonateHop = await rawGetImpersonate(url, reqHeaders, timeoutMs, maxBytes);
170
+ if (impersonateHop) {
171
+ lastTier = "impersonate";
172
+ return impersonateHop;
173
+ }
140
174
  const res = await fetch(url, {
141
175
  headers: reqHeaders,
142
176
  redirect: "manual",
143
177
  signal: AbortSignal.timeout(timeoutMs),
144
178
  });
179
+ lastTier = "fetch";
145
180
  return await read(res.status, headersToObject(res.headers), () => res.arrayBuffer());
146
181
  }
147
182
  }
148
183
  /**
149
- * Fetches a URL using `impit` (BoringSSL Chrome 136 TLS JA3/JA4 + HTTP/2 fingerprint impersonation)
184
+ * One GET hop with the curl-impersonate tier (Chrome 150 TLS/HTTP2 fingerprint).
185
+ * Used by `rawGet` when the impit hop is blocked (403/429/challenge) — newer
186
+ * fingerprints pass AWS WAF domains (stubhub, expedia) that block Chrome 136.
187
+ * Falls back to plain `fetch` when the binary is unavailable.
188
+ */
189
+ async function rawGetImpersonate(url, reqHeaders, timeoutMs, maxBytes) {
190
+ let impersonate;
191
+ try {
192
+ impersonate = await import("@webcrawl/curl-impersonate");
193
+ }
194
+ catch {
195
+ return null;
196
+ }
197
+ // The chrome150 profile's own identity headers MUST survive (profileHeaders
198
+ // merges extras last, so a stale UA would override the profile UA and the
199
+ // UA/TLS mismatch is exactly what DataDome/AWS WAF block). Keep cookies and
200
+ // referer, drop UA + sec-ch/client-hint identity headers.
201
+ const extra = {};
202
+ for (const [key, value] of Object.entries(reqHeaders)) {
203
+ const lower = key.toLowerCase();
204
+ if (lower === "user-agent" ||
205
+ lower.startsWith("sec-ch-ua") ||
206
+ lower.startsWith("sec-fetch-") ||
207
+ lower === "upgrade-insecure-requests" ||
208
+ lower === "priority") {
209
+ continue;
210
+ }
211
+ extra[key] = value;
212
+ }
213
+ try {
214
+ const res = await impersonate.impersonateFetch(url, {
215
+ headers: extra,
216
+ timeoutMs,
217
+ maxBytes,
218
+ });
219
+ const headers = res.headers;
220
+ const isRedirect = res.status >= 300 && res.status < 400 && !!headers["location"];
221
+ if (!isRedirect && maxBytes !== undefined)
222
+ checkDeclaredLength(headers, maxBytes);
223
+ return {
224
+ status: res.status,
225
+ headers,
226
+ body: isRedirect ? new ArrayBuffer(0) : res.body.buffer,
227
+ };
228
+ }
229
+ catch (err) {
230
+ if (err instanceof UnsafeUrlError)
231
+ throw err;
232
+ return null; // tier unavailable or fetch failed — caller falls back
233
+ }
234
+ }
235
+ /**
236
+ * Fetches a URL using `impit` (BoringSSL Chrome 136 TLS JA3/JA4 + HTTP/2 fingerprint impersonation)sonation)
150
237
  * so Cloudflare Bot Management and Vercel Security Checkpoint treat requests as real Chrome.
151
238
  * Falls back to standard `fetch` if `impit` is unavailable on the current platform.
152
239
  * Every redirect hop is checked against the SSRF guard.
@@ -10,7 +10,7 @@
10
10
  * powers both this local engine (`npx webcrawl`) and the hosted `webcrawl.app` API.
11
11
  */
12
12
  import { extractWebsiteData, fastHtmlToMarkdown, fetchWithTlsImpersonation, getCachedPageAsync, mapWebsiteUrls, setCachedPage, sha256, } from "./engine.js";
13
- import { fetchResource } from "./engine/fetcher.js";
13
+ import { fetchResource, lastFetchTier } from "./engine/fetcher.js";
14
14
  import { parseFile, publicParseMeta } from "./parse/index.js";
15
15
  import { isBinaryContentType, looksLikeBinaryUrl } from "./parse/fetch.js";
16
16
  import { maxDownloadBytes } from "./parse/limits.js";
@@ -174,7 +174,7 @@ export function createLocalEngineClient() {
174
174
  },
175
175
  evidence: {
176
176
  ...targetInspection.evidence,
177
- tier: "impit-tls",
177
+ tier: lastFetchTier() === "impersonate" ? "impersonate" : "impit-tls",
178
178
  contentHash,
179
179
  latencyMs: Date.now() - start,
180
180
  },
@@ -6,7 +6,7 @@
6
6
  * and returns structured results paired with verifiable Evidence & Provenance.
7
7
  */
8
8
  export type TargetCategory = "web" | "documents" | "media" | "commerce" | "travel" | "real-estate" | "local" | "security" | "developer" | "academic";
9
- export type TargetTier = "cache" | "static" | "impit-tls" | "cloudflare" | "stealth-browser" | "sandbox" | "native-parser" | "ocr" | "api";
9
+ export type TargetTier = "cache" | "static" | "impit-tls" | "impersonate" | "cloudflare" | "stealth-browser" | "sandbox" | "native-parser" | "ocr" | "api";
10
10
  export interface TargetEvidence {
11
11
  /** Which target plugin handled the inspection */
12
12
  targetId: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webcrawl",
3
- "version": "1.2.9",
3
+ "version": "1.3.1",
4
4
  "description": "Webcrawl Anything CLI & Core Engine — crawl, search, and extract structured evidence from any website, SPA, PDF, Office file, YouTube video, GitHub repo, ArXiv paper, or CVE",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -127,7 +127,8 @@
127
127
  "@ai-sdk/gateway": "^4.0.123",
128
128
  "@firecrawl/pdf-inspector": "^1.25.2",
129
129
  "@vercel/sandbox": "^1.0.0",
130
- "@webcrawl/client": "^1.2.9",
130
+ "@webcrawl/client": "^1.3.1",
131
+ "@webcrawl/curl-impersonate": "^1.3.1",
131
132
  "ai": "^7.0.150",
132
133
  "impit": "^0.14.7",
133
134
  "mammoth": "^1.13.0",