webcrawl 1.3.1 → 1.3.2

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" | "impersonate";
20
+ tier: "cache" | "static" | "sandbox" | "cloudflare" | "browser" | "impersonate" | "proxy-impersonate";
21
21
  latencyMs: number;
22
22
  error?: string;
23
23
  }
@@ -38,7 +38,15 @@ export declare function setDomainUserAgent(hostname: string, userAgent: string):
38
38
  export declare function isAwsWafChallenge(html: string, status?: number): boolean;
39
39
  export declare function isAntiBotChallenge(html: string, status: number): boolean;
40
40
  /** Transport tier that won the last fetch ladder run (for result evidence). */
41
- export declare function lastFetchTier(): "impit-tls" | "impersonate" | "fetch";
41
+ export declare function lastFetchTier(): "impit-tls" | "impersonate" | "proxy-impersonate" | "fetch";
42
+ /** Bytes transferred through the residential proxy on the last ladder run (bandwidth accounting). */
43
+ export declare function lastProxyBytesCount(): number;
44
+ /**
45
+ * Residential proxy URL from env, or null when the tier is disabled.
46
+ * Accepts a full `DECODO_URL` (`http://user:pass@host:port`) or the split
47
+ * `DECODO_USER` + `DECODO_PWD` pair (gateway defaults to `gate.decodo.com:7000`).
48
+ */
49
+ export declare function residentialProxyUrl(): string | null;
42
50
  /**
43
51
  * Fetches a URL using `impit` (BoringSSL Chrome 136 TLS JA3/JA4 + HTTP/2 fingerprint impersonation)sonation)
44
52
  * so Cloudflare Bot Management and Vercel Security Checkpoint treat requests as real Chrome.
@@ -1,4 +1,5 @@
1
1
  import { followSafely, UnsafeUrlError } from "./safe-url.js";
2
+ import { isDomainProxyNeeded, markDomainProxyNeeded, } from "./session.js";
2
3
  const domainCookies = new Map();
3
4
  const domainUserAgents = new Map();
4
5
  const BROWSER_USER_AGENTS = [
@@ -119,12 +120,34 @@ export function isAntiBotChallenge(html, status) {
119
120
  return isAwsWafChallenge(html, status);
120
121
  }
121
122
  let sharedImpit = null;
122
- /** Tier that won the most recent rawGet ladder ("impit-tls" → "impersonate" → "fetch"). */
123
+ /** Tier that won the most recent rawGet ladder ("impit-tls" → "impersonate" → "proxy-impersonate" → "fetch"). */
123
124
  let lastTier = "impit-tls";
125
+ /** Bytes pulled through the residential proxy on the most recent ladder run. */
126
+ let lastProxyBytes = 0;
124
127
  /** Transport tier that won the last fetch ladder run (for result evidence). */
125
128
  export function lastFetchTier() {
126
129
  return lastTier;
127
130
  }
131
+ /** Bytes transferred through the residential proxy on the last ladder run (bandwidth accounting). */
132
+ export function lastProxyBytesCount() {
133
+ return lastProxyBytes;
134
+ }
135
+ /**
136
+ * Residential proxy URL from env, or null when the tier is disabled.
137
+ * Accepts a full `DECODO_URL` (`http://user:pass@host:port`) or the split
138
+ * `DECODO_USER` + `DECODO_PWD` pair (gateway defaults to `gate.decodo.com:7000`).
139
+ */
140
+ export function residentialProxyUrl() {
141
+ const direct = process.env.DECODO_URL?.trim();
142
+ if (direct)
143
+ return direct;
144
+ const user = process.env.DECODO_USER?.trim();
145
+ const pwd = process.env.DECODO_PWD?.trim();
146
+ if (user && pwd) {
147
+ return `http://${encodeURIComponent(user)}:${encodeURIComponent(pwd)}@gate.decodo.com:7000`;
148
+ }
149
+ return null;
150
+ }
128
151
  /** One request, redirects NOT followed (callers validate each hop via followSafely). */
129
152
  async function rawGet(url, reqHeaders, timeoutMs, maxBytes) {
130
153
  const read = async (status, headers, getBuf) => {
@@ -133,6 +156,25 @@ async function rawGet(url, reqHeaders, timeoutMs, maxBytes) {
133
156
  checkDeclaredLength(headers, maxBytes);
134
157
  return { status, headers, body: isRedirect ? new ArrayBuffer(0) : await getBuf() };
135
158
  };
159
+ lastProxyBytes = 0;
160
+ // Tier P1 fast path: domains memoized as IP-blocked (DataDome) skip the
161
+ // datacenter attempts entirely and go straight to the residential proxy —
162
+ // saves latency and never burns proxy bytes on a doomed direct hop first.
163
+ const proxyUrl = residentialProxyUrl();
164
+ let host = "";
165
+ try {
166
+ host = new URL(url).hostname;
167
+ }
168
+ catch {
169
+ /* let the direct hops surface the URL error */
170
+ }
171
+ if (proxyUrl && host && (await isDomainProxyNeeded(host).catch(() => false))) {
172
+ const proxyHop = await rawGetImpersonateProxy(url, reqHeaders, timeoutMs, maxBytes, proxyUrl);
173
+ if (proxyHop) {
174
+ lastTier = "proxy-impersonate";
175
+ return proxyHop;
176
+ }
177
+ }
136
178
  try {
137
179
  if (!sharedImpit) {
138
180
  const impitMod = await import("impit");
@@ -155,6 +197,18 @@ async function rawGet(url, reqHeaders, timeoutMs, maxBytes) {
155
197
  if (isAntiBotChallenge(new TextDecoder().decode(hop.body).slice(0, 8000), hop.status)) {
156
198
  const impersonateHop = await rawGetImpersonate(url, reqHeaders, timeoutMs, maxBytes);
157
199
  if (impersonateHop) {
200
+ if (isAntiBotChallenge(new TextDecoder().decode(impersonateHop.body).slice(0, 8000), impersonateHop.status)) {
201
+ // Direct datacenter egress is IP-blocked (DataDome): last cheap try
202
+ // is the residential proxy hop before browser escalation. Costs
203
+ // ~100-300KB of proxy bandwidth instead of a 5-15MB page load.
204
+ if (proxyUrl) {
205
+ const proxyHop = await rawGetImpersonateProxy(url, reqHeaders, timeoutMs, maxBytes, proxyUrl);
206
+ if (proxyHop) {
207
+ lastTier = "proxy-impersonate";
208
+ return proxyHop;
209
+ }
210
+ }
211
+ }
158
212
  lastTier = "impersonate";
159
213
  return impersonateHop;
160
214
  }
@@ -232,6 +286,71 @@ async function rawGetImpersonate(url, reqHeaders, timeoutMs, maxBytes) {
232
286
  return null; // tier unavailable or fetch failed — caller falls back
233
287
  }
234
288
  }
289
+ /**
290
+ * Tier P1 — one GET hop with curl-impersonate through the residential proxy
291
+ * (Decodo or any `http://user:pass@host:port` HTTP proxy). The target sees a
292
+ * clean residential egress IP while the TLS/HTTP2 fingerprint stays a real
293
+ * Chrome's. Only reached for domains whose datacenter egress is proven
294
+ * IP-blocked, so the domain is memoized and later ladder runs skip the
295
+ * doomed direct attempts entirely. Gzipped HTML only (~100-300KB/request);
296
+ * a 2MB byte cap keeps a runaway response from draining proxy bandwidth.
297
+ */
298
+ async function rawGetImpersonateProxy(url, reqHeaders, timeoutMs, maxBytes, proxyUrl) {
299
+ let host = "";
300
+ try {
301
+ host = new URL(url).hostname;
302
+ }
303
+ catch {
304
+ /* URL errors surface via the direct hops */
305
+ }
306
+ if (host)
307
+ await markDomainProxyNeeded(host).catch(() => { });
308
+ let impersonate;
309
+ try {
310
+ impersonate = await import("@webcrawl/curl-impersonate");
311
+ }
312
+ catch {
313
+ return null;
314
+ }
315
+ // Same identity-header hygiene as the direct impersonate hop: the profile's
316
+ // UA/sec-ch headers must survive or the UA/TLS mismatch is what DataDome
317
+ // blocks even from a clean IP.
318
+ const extra = {};
319
+ for (const [key, value] of Object.entries(reqHeaders)) {
320
+ const lower = key.toLowerCase();
321
+ if (lower === "user-agent" ||
322
+ lower.startsWith("sec-ch-ua") ||
323
+ lower.startsWith("sec-fetch-") ||
324
+ lower === "upgrade-insecure-requests" ||
325
+ lower === "priority") {
326
+ continue;
327
+ }
328
+ extra[key] = value;
329
+ }
330
+ try {
331
+ const res = await impersonate.impersonateFetch(url, {
332
+ headers: extra,
333
+ timeoutMs,
334
+ maxBytes: maxBytes ?? 2 * 1024 * 1024,
335
+ proxy: proxyUrl,
336
+ });
337
+ lastProxyBytes += res.bytes;
338
+ const headers = res.headers;
339
+ const isRedirect = res.status >= 300 && res.status < 400 && !!headers["location"];
340
+ if (!isRedirect && maxBytes !== undefined)
341
+ checkDeclaredLength(headers, maxBytes);
342
+ return {
343
+ status: res.status,
344
+ headers,
345
+ body: isRedirect ? new ArrayBuffer(0) : res.body.buffer,
346
+ };
347
+ }
348
+ catch (err) {
349
+ if (err instanceof UnsafeUrlError)
350
+ throw err;
351
+ return null; // proxy tier unavailable or fetch failed — caller escalates
352
+ }
353
+ }
235
354
  /**
236
355
  * Fetches a URL using `impit` (BoringSSL Chrome 136 TLS JA3/JA4 + HTTP/2 fingerprint impersonation)sonation)
237
356
  * so Cloudflare Bot Management and Vercel Security Checkpoint treat requests as real Chrome.
@@ -35,6 +35,9 @@ export interface ExternalSessionStore {
35
35
  deleteWarmSession(domain: string): Promise<void>;
36
36
  markDomainChallenged(domain: string): Promise<void>;
37
37
  isDomainChallenged(domain: string): Promise<boolean>;
38
+ /** Optional persistence for the residential-proxy memo (Tier P1). */
39
+ markDomainProxyNeeded?(domain: string): Promise<void>;
40
+ isDomainProxyNeeded?(domain: string): Promise<boolean>;
38
41
  }
39
42
  /** How long a solved session is offered for warm reconnect / cookie replay. */
40
43
  export declare const WARM_TTL_MS: number;
@@ -44,6 +47,8 @@ export declare const WARM_SANDBOX_TIMEOUT_MS: number;
44
47
  export declare const WARM_CAP = 4;
45
48
  /** How long a domain stays on the JS-challenge memo. */
46
49
  export declare const CHALLENGE_MEMO_TTL_MS: number;
50
+ /** How long a domain stays on the residential-proxy memo (Tier P1). */
51
+ export declare const PROXY_MEMO_TTL_MS: number;
47
52
  export declare function registerExternalSessionStore(store: ExternalSessionStore): void;
48
53
  export interface CookieLike {
49
54
  name: string;
@@ -65,5 +70,12 @@ export declare function deleteWarmSession(domain: string): Promise<void>;
65
70
  /** Marks a domain as known to serve JS challenges (skips the curl tier next time). */
66
71
  export declare function markDomainChallenged(domain: string): Promise<void>;
67
72
  export declare function isDomainChallenged(domain: string): Promise<boolean>;
73
+ /**
74
+ * Marks a domain as needing the residential-proxy tier (Tier P1): every
75
+ * datacenter egress attempt is blocked by IP reputation (DataDome et al), so
76
+ * future ladder runs skip straight to the proxy hop.
77
+ */
78
+ export declare function markDomainProxyNeeded(domain: string): Promise<void>;
79
+ export declare function isDomainProxyNeeded(domain: string): Promise<boolean>;
68
80
  /** Test helper: resets all in-memory state (external stores are untouched). */
69
81
  export declare function resetSessionStateForTests(): void;
@@ -27,12 +27,15 @@ export const WARM_SANDBOX_TIMEOUT_MS = 10 * 60_000;
27
27
  export const WARM_CAP = 4;
28
28
  /** How long a domain stays on the JS-challenge memo. */
29
29
  export const CHALLENGE_MEMO_TTL_MS = 30 * 60_000;
30
+ /** How long a domain stays on the residential-proxy memo (Tier P1). */
31
+ export const PROXY_MEMO_TTL_MS = 60 * 60_000;
30
32
  let external = null;
31
33
  export function registerExternalSessionStore(store) {
32
34
  external = store;
33
35
  }
34
36
  const warm = new Map();
35
37
  const challenged = new Map();
38
+ const proxyNeeded = new Map();
36
39
  function reap(now = Date.now()) {
37
40
  for (const [d, s] of warm)
38
41
  if (s.expiresAt <= now)
@@ -40,6 +43,9 @@ function reap(now = Date.now()) {
40
43
  for (const [d, t] of challenged)
41
44
  if (t <= now)
42
45
  challenged.delete(d);
46
+ for (const [d, t] of proxyNeeded)
47
+ if (t <= now)
48
+ proxyNeeded.delete(d);
43
49
  }
44
50
  /** Serializes captured browser cookies into a `Cookie:` header value (drops expired). */
45
51
  export function cookieHeaderFrom(cookies) {
@@ -149,8 +155,49 @@ export async function isDomainChallenged(domain) {
149
155
  }
150
156
  return false;
151
157
  }
158
+ /**
159
+ * Marks a domain as needing the residential-proxy tier (Tier P1): every
160
+ * datacenter egress attempt is blocked by IP reputation (DataDome et al), so
161
+ * future ladder runs skip straight to the proxy hop.
162
+ */
163
+ export async function markDomainProxyNeeded(domain) {
164
+ if (!domain)
165
+ return;
166
+ proxyNeeded.set(domain, Date.now() + PROXY_MEMO_TTL_MS);
167
+ if (external?.markDomainProxyNeeded) {
168
+ try {
169
+ await external.markDomainProxyNeeded(domain);
170
+ }
171
+ catch {
172
+ /* store unavailable */
173
+ }
174
+ }
175
+ }
176
+ export async function isDomainProxyNeeded(domain) {
177
+ if (!domain)
178
+ return false;
179
+ reap();
180
+ const until = proxyNeeded.get(domain);
181
+ if (until && until > Date.now())
182
+ return true;
183
+ if (until)
184
+ proxyNeeded.delete(domain);
185
+ if (external?.isDomainProxyNeeded) {
186
+ try {
187
+ if (await external.isDomainProxyNeeded(domain)) {
188
+ proxyNeeded.set(domain, Date.now() + PROXY_MEMO_TTL_MS);
189
+ return true;
190
+ }
191
+ }
192
+ catch {
193
+ /* store unavailable */
194
+ }
195
+ }
196
+ return false;
197
+ }
152
198
  /** Test helper: resets all in-memory state (external stores are untouched). */
153
199
  export function resetSessionStateForTests() {
154
200
  warm.clear();
155
201
  challenged.clear();
202
+ proxyNeeded.clear();
156
203
  }
@@ -47,6 +47,8 @@ export declare function stealthFetch(entryUrl: string, items: StealthItem[], opt
47
47
  remote?: boolean;
48
48
  /** Keep the solved session warm (sandbox left running + cookies saved) for repeat queries. */
49
49
  keepWarm?: boolean;
50
+ /** Tier P2: egress the stealth browser through this residential proxy URL. */
51
+ proxy?: string;
50
52
  }): Promise<StealthResult>;
51
53
  /** Extracts `<loc>` entries from a sitemap or sitemap index. */
52
54
  export declare function parseSitemapLocs(xml: string): {
@@ -40,9 +40,23 @@ export async function stealthFetch(entryUrl, items, opts = {}) {
40
40
  const { startBrowser, runBrowserStep, stopBrowser } = await import("../interact/driver.js");
41
41
  const provider = opts.remote === false ? null : remoteStealthProvider();
42
42
  const domain = hostOf(entryUrl);
43
+ const proxyUrl = opts.proxy ?? null;
44
+ let proxyUser = "";
45
+ let proxyPass = "";
46
+ if (proxyUrl) {
47
+ try {
48
+ const parsed = new URL(proxyUrl);
49
+ proxyUser = decodeURIComponent(parsed.username);
50
+ proxyPass = decodeURIComponent(parsed.password);
51
+ }
52
+ catch {
53
+ /* bad proxy URL: browser launch will surface the error */
54
+ }
55
+ }
43
56
  // A warm session for this domain means the challenge is already solved: short
44
- // solve budget. A cold solve needs the full budget.
45
- const hadWarm = provider ? null : await getWarmSession(domain).catch(() => null);
57
+ // solve budget. A cold solve needs the full budget. Proxy runs never reuse
58
+ // warm sessions (their cookies were earned from a datacenter egress IP).
59
+ const hadWarm = provider || proxyUrl ? null : await getWarmSession(domain).catch(() => null);
46
60
  const tag = provider ? { provider } : { warm: Boolean(hadWarm) };
47
61
  let handle = null;
48
62
  // Filled in by `finally` once the provider reports the session cost.
@@ -54,7 +68,8 @@ export async function stealthFetch(entryUrl, items, opts = {}) {
54
68
  handle = await startBrowser({
55
69
  timeoutMs: Math.min(5 * 60_000, budgetMs + 90_000),
56
70
  remote: provider,
57
- domain: provider ? undefined : domain,
71
+ domain: provider || proxyUrl ? undefined : domain,
72
+ proxy: proxyUrl ?? undefined,
58
73
  });
59
74
  const res = await runBrowserStep(handle, {
60
75
  type: "fetchMany",
@@ -63,6 +78,7 @@ export async function stealthFetch(entryUrl, items, opts = {}) {
63
78
  budgetMs,
64
79
  solveMs: hadWarm ? Math.min(opts.solveMs ?? 20_000, 8_000) : (opts.solveMs ?? 20_000),
65
80
  followSitemaps: opts.followSitemaps ?? 0,
81
+ ...(proxyUser ? { proxyUser, proxyPass, proxyByteCap: 6_000_000 } : {}),
66
82
  });
67
83
  if (!res.ok) {
68
84
  out.result = { solved: false, entry: null, docs: [], durationMs: 0, error: res.error || "Stealth browser step failed", ...tag };
@@ -43,6 +43,7 @@ export declare function startBrowser(opts?: {
43
43
  userAgent?: string;
44
44
  remote?: RemoteProvider | null;
45
45
  domain?: string;
46
+ proxy?: string;
46
47
  }): Promise<BrowserHandle>;
47
48
  export declare function runBrowserStep(handle: BrowserHandle, action: StepAction): Promise<StepResultLine>;
48
49
  /**
@@ -81,7 +81,7 @@ async function startSandboxBrowser(opts) {
81
81
  const exe = (await sh(sandbox, `cat ${VM_ROOT}/chrome-path`)).stdout.trim().split("\n").pop();
82
82
  if (!exe)
83
83
  throw new Error("Browser binary not found in sandbox");
84
- await sandbox.runCommand({ cmd: exe, args: chromeArgs({ userAgent: opts.userAgent }), detached: true, sudo: true });
84
+ await sandbox.runCommand({ cmd: exe, args: chromeArgs({ userAgent: opts.userAgent, proxy: opts.proxy }), detached: true, sudo: true });
85
85
  const ready = await sh(sandbox, WAIT_FOR_CDP_SCRIPT, false);
86
86
  if (ready.exitCode !== 0)
87
87
  throw new Error("Browser did not start");
@@ -153,7 +153,7 @@ async function startLocalBrowser(opts) {
153
153
  const { playwright, chrome } = await resolveLocalPlaywright();
154
154
  const port = 9300 + Math.floor(Math.random() * 600);
155
155
  const dir = mkdtempSync(path.join(tmpdir(), "wc-interact-"));
156
- const args = chromeArgs({ userAgent: opts.userAgent })
156
+ const args = chromeArgs({ userAgent: opts.userAgent, proxy: opts.proxy })
157
157
  .map((a) => (a.startsWith("--remote-debugging-port=") ? `--remote-debugging-port=${port}` : a))
158
158
  .map((a) => (a.startsWith("--user-data-dir=") ? `--user-data-dir=${dir}/profile` : a));
159
159
  args.unshift("--disable-crash-reporter", "--disable-breakpad");
@@ -254,7 +254,8 @@ export async function startBrowser(opts = {}) {
254
254
  throw new Error("browser_unavailable");
255
255
  // Warm reconnect first: a sandbox that already solved this domain's bot check
256
256
  // keeps its IP / UA / cleared cookies — reusing it skips boot + re-solve.
257
- if (opts.domain) {
257
+ // Never warm-reconnect when proxying: the warm sandbox egresses direct.
258
+ if (opts.domain && !opts.proxy) {
258
259
  const warm = await startWarmSandboxBrowser(opts.domain).catch(() => null);
259
260
  if (warm)
260
261
  return warm;
@@ -263,6 +264,7 @@ export async function startBrowser(opts = {}) {
263
264
  // Long VM timeout headroom so the sandbox can stay warm after this call.
264
265
  timeoutMs: Math.max(opts.timeoutMs ?? 5 * 60_000, WARM_SANDBOX_TIMEOUT_MS),
265
266
  userAgent: opts.userAgent,
267
+ proxy: opts.proxy,
266
268
  });
267
269
  }
268
270
  function runLocalStep(scriptPath, playwright, cdp, action, extraEnv = {}) {
@@ -18,6 +18,7 @@ export declare function chromeArgs(opts?: {
18
18
  userAgent?: string;
19
19
  width?: number;
20
20
  height?: number;
21
+ proxy?: string;
21
22
  }): string[];
22
23
  /** Waits until the CDP endpoint answers. Exit 1 if it never does. */
23
24
  export declare const WAIT_FOR_CDP_SCRIPT = "for i in $(seq 1 50); do curl -sf http://127.0.0.1:9222/json/version >/dev/null && echo up && exit 0; sleep 0.2; done; echo \"browser did not start\" >&2; exit 1";
@@ -58,6 +58,11 @@ export function chromeArgs(opts = {}) {
58
58
  ];
59
59
  if (opts.userAgent)
60
60
  args.push(`--user-agent=${opts.userAgent}`);
61
+ // Residential-proxy browser tier (P2): all browser traffic egresses via the
62
+ // proxy. Chromium cannot parse credentials in --proxy-server, so they are
63
+ // stripped here and re-supplied per-page via proxy auth in the step script.
64
+ if (opts.proxy)
65
+ args.push(`--proxy-server=${opts.proxy.replace(/\/\/[^@/]+@/, "//")}`);
61
66
  args.push("about:blank");
62
67
  return args;
63
68
  }
@@ -178,9 +183,29 @@ try {
178
183
  await cdp.send("Page.addScriptToEvaluateOnNewDocument", { source: "Object.defineProperty(Navigator.prototype,'webdriver',{get:()=>false});" });
179
184
  } catch {}
180
185
  // Remote browsers bill proxy traffic per GB: skip bytes markdown never uses.
181
- if (action.lightweight) {
182
- const skip = new Set(["image", "media", "font"]);
183
- await page.route("**/*", (r) => (skip.has(r.request().resourceType()) ? r.abort() : r.continue())).catch(() => {});
186
+ // The proxy tier (P2) blocks harder — stylesheets and analytics beacons
187
+ // too — because residential-proxy bandwidth is billed per MB and markdown
188
+ // only needs the document, its scripts and the challenge sensor (the
189
+ // sensor host is never on the ad blocklist). A byte cap aborts the rest
190
+ // of the page once the billed total blows past the budget.
191
+ let proxyBilled = 0;
192
+ if (action.lightweight || action.proxyUser) {
193
+ const skip = new Set(action.proxyUser ? ["image", "media", "font", "stylesheet"] : ["image", "media", "font"]);
194
+ const adRe = /(google-analytics|googletagmanager|doubleclick|adservice\.google|facebook\.net|connect\.facebook|hotjar|segment\.io|clarity\.ms|bat\.bing|scorecardresearch|newrelic|optimizely|criteo|taboola|outbrain)/i;
195
+ const cap = action.proxyUser ? Math.max(2_000_000, Math.min(Number(action.proxyByteCap) || 6_000_000, 12_000_000)) : Infinity;
196
+ page.on("response", (r) => { try { proxyBilled += Number(r.headers()["content-length"] || 0) || 0; } catch {} });
197
+ await page.route("**/*", (r) => {
198
+ try {
199
+ const req = r.request();
200
+ if (proxyBilled > cap) return r.abort();
201
+ if (skip.has(req.resourceType())) return r.abort();
202
+ if (adRe.test(req.url())) return r.abort();
203
+ return r.continue();
204
+ } catch { return r.continue(); }
205
+ }).catch(() => {});
206
+ if (action.proxyUser) {
207
+ try { await page.authenticate({ username: String(action.proxyUser), password: String(action.proxyPass || "") }); } catch {}
208
+ }
184
209
  }
185
210
  let navStatus = null;
186
211
  page.on("response", (r) => { try { if (r.request().isNavigationRequest() && r.frame() === page.mainFrame()) navStatus = r.status(); } catch {} });
@@ -174,7 +174,11 @@ export function createLocalEngineClient() {
174
174
  },
175
175
  evidence: {
176
176
  ...targetInspection.evidence,
177
- tier: lastFetchTier() === "impersonate" ? "impersonate" : "impit-tls",
177
+ tier: lastFetchTier() === "impersonate"
178
+ ? "impersonate"
179
+ : lastFetchTier() === "proxy-impersonate"
180
+ ? "proxy-impersonate"
181
+ : "impit-tls",
178
182
  contentHash,
179
183
  latencyMs: Date.now() - start,
180
184
  },
@@ -181,7 +181,23 @@ async function escalateViaStealthBrowser(url, options) {
181
181
  const { stealthAvailable, stealthFetch } = await import("./engine/stealth.js");
182
182
  if (!(await stealthAvailable()))
183
183
  return null;
184
- const res = await stealthFetch(url, [], { budgetMs: options.browserBudgetMs ?? 30_000 });
184
+ // Tier P2: when the domain is memoized as needing a residential egress,
185
+ // the stealth browser itself egresses through the proxy (resource-blocked,
186
+ // byte-capped inside stealthFetch) instead of a doomed datacenter IP.
187
+ let proxy;
188
+ try {
189
+ const { residentialProxyUrl } = await import("./engine/fetcher.js");
190
+ const { isDomainProxyNeeded } = await import("./engine/session.js");
191
+ let host = "";
192
+ try {
193
+ host = new URL(url).hostname;
194
+ }
195
+ catch { /* bad url handled below */ }
196
+ if (host && (await isDomainProxyNeeded(host)))
197
+ proxy = residentialProxyUrl() ?? undefined;
198
+ }
199
+ catch { /* proxy memo unavailable — datacenter stealth as before */ }
200
+ const res = await stealthFetch(url, [], { budgetMs: options.browserBudgetMs ?? 30_000, ...(proxy ? { proxy } : {}) });
185
201
  const entry = res.entry;
186
202
  if (!entry?.body || entry.blocked || isAntiBotChallenge(entry.body, entry.status ?? 200)) {
187
203
  return null;
@@ -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" | "impersonate" | "cloudflare" | "stealth-browser" | "sandbox" | "native-parser" | "ocr" | "api";
9
+ export type TargetTier = "cache" | "static" | "impit-tls" | "impersonate" | "proxy-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.3.1",
3
+ "version": "1.3.2",
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,8 +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.3.1",
131
- "@webcrawl/curl-impersonate": "^1.3.1",
130
+ "@webcrawl/client": "^1.3.2",
131
+ "@webcrawl/curl-impersonate": "^1.3.2",
132
132
  "ai": "^7.0.150",
133
133
  "impit": "^0.14.7",
134
134
  "mammoth": "^1.13.0",