webcrawl 1.3.1 → 1.3.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin.js +0 -0
- package/dist/core/engine/fetcher.d.ts +13 -2
- package/dist/core/engine/fetcher.js +125 -1
- package/dist/core/engine/session.d.ts +12 -0
- package/dist/core/engine/session.js +47 -0
- package/dist/core/engine/stealth.d.ts +2 -0
- package/dist/core/engine/stealth.js +19 -3
- package/dist/core/interact/driver.d.ts +10 -0
- package/dist/core/interact/driver.js +42 -5
- package/dist/core/interact/vm.d.ts +1 -0
- package/dist/core/interact/vm.js +36 -3
- package/dist/core/local-engine.js +5 -1
- package/dist/core/sandbox.js +17 -1
- package/dist/core/targets/types.d.ts +1 -1
- package/package.json +3 -3
package/dist/bin.js
CHANGED
|
File without changes
|
|
@@ -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,18 @@ 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
|
+
* Generic: a full `WEBCRAWL_PROXY_URL` (`http://user:pass@host:port`) or the
|
|
47
|
+
* split `WEBCRAWL_PROXY_USER` + `WEBCRAWL_PROXY_PWD` pair, with the gateway
|
|
48
|
+
* host/port overridable via `WEBCRAWL_PROXY_HOST` / `WEBCRAWL_PROXY_PORT`.
|
|
49
|
+
* `DECODO_*` equivalents are honored as a legacy fallback so existing
|
|
50
|
+
* deployments keep working (their default gateway is `gate.decodo.com:7000`).
|
|
51
|
+
*/
|
|
52
|
+
export declare function residentialProxyUrl(): string | null;
|
|
42
53
|
/**
|
|
43
54
|
* Fetches a URL using `impit` (BoringSSL Chrome 136 TLS JA3/JA4 + HTTP/2 fingerprint impersonation)sonation)
|
|
44
55
|
* 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,39 @@ 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
|
+
* Generic: a full `WEBCRAWL_PROXY_URL` (`http://user:pass@host:port`) or the
|
|
138
|
+
* split `WEBCRAWL_PROXY_USER` + `WEBCRAWL_PROXY_PWD` pair, with the gateway
|
|
139
|
+
* host/port overridable via `WEBCRAWL_PROXY_HOST` / `WEBCRAWL_PROXY_PORT`.
|
|
140
|
+
* `DECODO_*` equivalents are honored as a legacy fallback so existing
|
|
141
|
+
* deployments keep working (their default gateway is `gate.decodo.com:7000`).
|
|
142
|
+
*/
|
|
143
|
+
export function residentialProxyUrl() {
|
|
144
|
+
const direct = process.env.WEBCRAWL_PROXY_URL?.trim() || process.env.DECODO_URL?.trim();
|
|
145
|
+
if (direct)
|
|
146
|
+
return direct;
|
|
147
|
+
const user = process.env.WEBCRAWL_PROXY_USER?.trim() || process.env.DECODO_USER?.trim();
|
|
148
|
+
const pwd = process.env.WEBCRAWL_PROXY_PWD?.trim() || process.env.DECODO_PWD?.trim();
|
|
149
|
+
if (user && pwd) {
|
|
150
|
+
const host = process.env.WEBCRAWL_PROXY_HOST?.trim() || "gate.decodo.com";
|
|
151
|
+
const port = process.env.WEBCRAWL_PROXY_PORT?.trim() || "7000";
|
|
152
|
+
return `http://${encodeURIComponent(user)}:${encodeURIComponent(pwd)}@${host}:${port}`;
|
|
153
|
+
}
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
128
156
|
/** One request, redirects NOT followed (callers validate each hop via followSafely). */
|
|
129
157
|
async function rawGet(url, reqHeaders, timeoutMs, maxBytes) {
|
|
130
158
|
const read = async (status, headers, getBuf) => {
|
|
@@ -133,6 +161,25 @@ async function rawGet(url, reqHeaders, timeoutMs, maxBytes) {
|
|
|
133
161
|
checkDeclaredLength(headers, maxBytes);
|
|
134
162
|
return { status, headers, body: isRedirect ? new ArrayBuffer(0) : await getBuf() };
|
|
135
163
|
};
|
|
164
|
+
lastProxyBytes = 0;
|
|
165
|
+
// Tier P1 fast path: domains memoized as IP-blocked (DataDome) skip the
|
|
166
|
+
// datacenter attempts entirely and go straight to the residential proxy —
|
|
167
|
+
// saves latency and never burns proxy bytes on a doomed direct hop first.
|
|
168
|
+
const proxyUrl = residentialProxyUrl();
|
|
169
|
+
let host = "";
|
|
170
|
+
try {
|
|
171
|
+
host = new URL(url).hostname;
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
/* let the direct hops surface the URL error */
|
|
175
|
+
}
|
|
176
|
+
if (proxyUrl && host && (await isDomainProxyNeeded(host).catch(() => false))) {
|
|
177
|
+
const proxyHop = await rawGetImpersonateProxy(url, reqHeaders, timeoutMs, maxBytes, proxyUrl);
|
|
178
|
+
if (proxyHop) {
|
|
179
|
+
lastTier = "proxy-impersonate";
|
|
180
|
+
return proxyHop;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
136
183
|
try {
|
|
137
184
|
if (!sharedImpit) {
|
|
138
185
|
const impitMod = await import("impit");
|
|
@@ -155,6 +202,18 @@ async function rawGet(url, reqHeaders, timeoutMs, maxBytes) {
|
|
|
155
202
|
if (isAntiBotChallenge(new TextDecoder().decode(hop.body).slice(0, 8000), hop.status)) {
|
|
156
203
|
const impersonateHop = await rawGetImpersonate(url, reqHeaders, timeoutMs, maxBytes);
|
|
157
204
|
if (impersonateHop) {
|
|
205
|
+
if (isAntiBotChallenge(new TextDecoder().decode(impersonateHop.body).slice(0, 8000), impersonateHop.status)) {
|
|
206
|
+
// Direct datacenter egress is IP-blocked (DataDome): last cheap try
|
|
207
|
+
// is the residential proxy hop before browser escalation. Costs
|
|
208
|
+
// ~100-300KB of proxy bandwidth instead of a 5-15MB page load.
|
|
209
|
+
if (proxyUrl) {
|
|
210
|
+
const proxyHop = await rawGetImpersonateProxy(url, reqHeaders, timeoutMs, maxBytes, proxyUrl);
|
|
211
|
+
if (proxyHop) {
|
|
212
|
+
lastTier = "proxy-impersonate";
|
|
213
|
+
return proxyHop;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
158
217
|
lastTier = "impersonate";
|
|
159
218
|
return impersonateHop;
|
|
160
219
|
}
|
|
@@ -232,6 +291,71 @@ async function rawGetImpersonate(url, reqHeaders, timeoutMs, maxBytes) {
|
|
|
232
291
|
return null; // tier unavailable or fetch failed — caller falls back
|
|
233
292
|
}
|
|
234
293
|
}
|
|
294
|
+
/**
|
|
295
|
+
* Tier P1 — one GET hop with curl-impersonate through the residential proxy
|
|
296
|
+
* (Decodo or any `http://user:pass@host:port` HTTP proxy). The target sees a
|
|
297
|
+
* clean residential egress IP while the TLS/HTTP2 fingerprint stays a real
|
|
298
|
+
* Chrome's. Only reached for domains whose datacenter egress is proven
|
|
299
|
+
* IP-blocked, so the domain is memoized and later ladder runs skip the
|
|
300
|
+
* doomed direct attempts entirely. Gzipped HTML only (~100-300KB/request);
|
|
301
|
+
* a 2MB byte cap keeps a runaway response from draining proxy bandwidth.
|
|
302
|
+
*/
|
|
303
|
+
async function rawGetImpersonateProxy(url, reqHeaders, timeoutMs, maxBytes, proxyUrl) {
|
|
304
|
+
let host = "";
|
|
305
|
+
try {
|
|
306
|
+
host = new URL(url).hostname;
|
|
307
|
+
}
|
|
308
|
+
catch {
|
|
309
|
+
/* URL errors surface via the direct hops */
|
|
310
|
+
}
|
|
311
|
+
if (host)
|
|
312
|
+
await markDomainProxyNeeded(host).catch(() => { });
|
|
313
|
+
let impersonate;
|
|
314
|
+
try {
|
|
315
|
+
impersonate = await import("@webcrawl/curl-impersonate");
|
|
316
|
+
}
|
|
317
|
+
catch {
|
|
318
|
+
return null;
|
|
319
|
+
}
|
|
320
|
+
// Same identity-header hygiene as the direct impersonate hop: the profile's
|
|
321
|
+
// UA/sec-ch headers must survive or the UA/TLS mismatch is what DataDome
|
|
322
|
+
// blocks even from a clean IP.
|
|
323
|
+
const extra = {};
|
|
324
|
+
for (const [key, value] of Object.entries(reqHeaders)) {
|
|
325
|
+
const lower = key.toLowerCase();
|
|
326
|
+
if (lower === "user-agent" ||
|
|
327
|
+
lower.startsWith("sec-ch-ua") ||
|
|
328
|
+
lower.startsWith("sec-fetch-") ||
|
|
329
|
+
lower === "upgrade-insecure-requests" ||
|
|
330
|
+
lower === "priority") {
|
|
331
|
+
continue;
|
|
332
|
+
}
|
|
333
|
+
extra[key] = value;
|
|
334
|
+
}
|
|
335
|
+
try {
|
|
336
|
+
const res = await impersonate.impersonateFetch(url, {
|
|
337
|
+
headers: extra,
|
|
338
|
+
timeoutMs,
|
|
339
|
+
maxBytes: maxBytes ?? 2 * 1024 * 1024,
|
|
340
|
+
proxy: proxyUrl,
|
|
341
|
+
});
|
|
342
|
+
lastProxyBytes += res.bytes;
|
|
343
|
+
const headers = res.headers;
|
|
344
|
+
const isRedirect = res.status >= 300 && res.status < 400 && !!headers["location"];
|
|
345
|
+
if (!isRedirect && maxBytes !== undefined)
|
|
346
|
+
checkDeclaredLength(headers, maxBytes);
|
|
347
|
+
return {
|
|
348
|
+
status: res.status,
|
|
349
|
+
headers,
|
|
350
|
+
body: isRedirect ? new ArrayBuffer(0) : res.body.buffer,
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
catch (err) {
|
|
354
|
+
if (err instanceof UnsafeUrlError)
|
|
355
|
+
throw err;
|
|
356
|
+
return null; // proxy tier unavailable or fetch failed — caller escalates
|
|
357
|
+
}
|
|
358
|
+
}
|
|
235
359
|
/**
|
|
236
360
|
* Fetches a URL using `impit` (BoringSSL Chrome 136 TLS JA3/JA4 + HTTP/2 fingerprint impersonation)sonation)
|
|
237
361
|
* 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
|
-
|
|
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 };
|
|
@@ -38,11 +38,21 @@ export type StepAction = Record<string, unknown> & {
|
|
|
38
38
|
*/
|
|
39
39
|
export declare function interactBrowserAvailable(): boolean;
|
|
40
40
|
export declare function interactBackend(): "local" | "sandbox" | "none";
|
|
41
|
+
/**
|
|
42
|
+
* Path to a real, user-installed Chrome when one exists, else null.
|
|
43
|
+
* Anti-bot vendors (DataDome, Akamai) fingerprint the automation build of
|
|
44
|
+
* Chromium and serve degraded pages to it, while the same engine driving a
|
|
45
|
+
* real installed Chrome gets full content — practitioner reports confirm
|
|
46
|
+
* this on DataDome-protected sites. Priority: explicit env override, then
|
|
47
|
+
* the standard per-OS install locations.
|
|
48
|
+
*/
|
|
49
|
+
export declare function installedChromePath(): string | null;
|
|
41
50
|
export declare function startBrowser(opts?: {
|
|
42
51
|
timeoutMs?: number;
|
|
43
52
|
userAgent?: string;
|
|
44
53
|
remote?: RemoteProvider | null;
|
|
45
54
|
domain?: string;
|
|
55
|
+
proxy?: string;
|
|
46
56
|
}): Promise<BrowserHandle>;
|
|
47
57
|
export declare function runBrowserStep(handle: BrowserHandle, action: StepAction): Promise<StepResultLine>;
|
|
48
58
|
/**
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* (Local World / `next dev`).
|
|
10
10
|
*/
|
|
11
11
|
import { execFile, spawn } from "node:child_process";
|
|
12
|
-
import { mkdtempSync, writeFileSync } from "node:fs";
|
|
12
|
+
import { existsSync, mkdtempSync, writeFileSync } from "node:fs";
|
|
13
13
|
import { tmpdir } from "node:os";
|
|
14
14
|
import path from "node:path";
|
|
15
15
|
import { Sandbox } from "@vercel/sandbox";
|
|
@@ -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");
|
|
@@ -131,6 +131,38 @@ async function startWarmSandboxBrowser(domain) {
|
|
|
131
131
|
}
|
|
132
132
|
}
|
|
133
133
|
/* ------------------------------ local backend ----------------------------- */
|
|
134
|
+
/**
|
|
135
|
+
* Path to a real, user-installed Chrome when one exists, else null.
|
|
136
|
+
* Anti-bot vendors (DataDome, Akamai) fingerprint the automation build of
|
|
137
|
+
* Chromium and serve degraded pages to it, while the same engine driving a
|
|
138
|
+
* real installed Chrome gets full content — practitioner reports confirm
|
|
139
|
+
* this on DataDome-protected sites. Priority: explicit env override, then
|
|
140
|
+
* the standard per-OS install locations.
|
|
141
|
+
*/
|
|
142
|
+
export function installedChromePath() {
|
|
143
|
+
if (process.env.INTERACT_CHROME_PATH)
|
|
144
|
+
return process.env.INTERACT_CHROME_PATH;
|
|
145
|
+
const candidates = [];
|
|
146
|
+
if (process.platform === "darwin") {
|
|
147
|
+
candidates.push("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", `${process.env.HOME || ""}/Applications/Google Chrome.app/Contents/MacOS/Google Chrome`);
|
|
148
|
+
}
|
|
149
|
+
else if (process.platform === "win32") {
|
|
150
|
+
candidates.push(`${process.env["ProgramFiles"] || "C:\\Program Files"}\\Google\\Chrome\\Application\\chrome.exe`, `${process.env["ProgramFiles(x86)"] || "C:\\Program Files (x86)"}\\Google\\Chrome\\Application\\chrome.exe`);
|
|
151
|
+
}
|
|
152
|
+
else {
|
|
153
|
+
candidates.push("/usr/bin/google-chrome", "/usr/bin/google-chrome-stable", "/usr/bin/chromium-browser", "/usr/bin/chromium", "/snap/bin/chromium");
|
|
154
|
+
}
|
|
155
|
+
for (const p of candidates) {
|
|
156
|
+
try {
|
|
157
|
+
if (existsSync(p))
|
|
158
|
+
return p;
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
/* not installed here */
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
134
166
|
async function resolveLocalPlaywright() {
|
|
135
167
|
const candidates = [
|
|
136
168
|
process.env.WC_PLAYWRIGHT,
|
|
@@ -140,7 +172,10 @@ async function resolveLocalPlaywright() {
|
|
|
140
172
|
for (const p of candidates) {
|
|
141
173
|
try {
|
|
142
174
|
const mod = (await import(/* webpackIgnore: true */ p));
|
|
143
|
-
|
|
175
|
+
// Prefer a real installed Chrome over playwright's bundled automation
|
|
176
|
+
// Chromium (see installedChromePath); fall back to the bundle when the
|
|
177
|
+
// host has no Chrome (CI, containers, minimal servers).
|
|
178
|
+
const chrome = installedChromePath() || mod.chromium.executablePath();
|
|
144
179
|
return { playwright: p, chrome };
|
|
145
180
|
}
|
|
146
181
|
catch {
|
|
@@ -153,7 +188,7 @@ async function startLocalBrowser(opts) {
|
|
|
153
188
|
const { playwright, chrome } = await resolveLocalPlaywright();
|
|
154
189
|
const port = 9300 + Math.floor(Math.random() * 600);
|
|
155
190
|
const dir = mkdtempSync(path.join(tmpdir(), "wc-interact-"));
|
|
156
|
-
const args = chromeArgs({ userAgent: opts.userAgent })
|
|
191
|
+
const args = chromeArgs({ userAgent: opts.userAgent, proxy: opts.proxy })
|
|
157
192
|
.map((a) => (a.startsWith("--remote-debugging-port=") ? `--remote-debugging-port=${port}` : a))
|
|
158
193
|
.map((a) => (a.startsWith("--user-data-dir=") ? `--user-data-dir=${dir}/profile` : a));
|
|
159
194
|
args.unshift("--disable-crash-reporter", "--disable-breakpad");
|
|
@@ -254,7 +289,8 @@ export async function startBrowser(opts = {}) {
|
|
|
254
289
|
throw new Error("browser_unavailable");
|
|
255
290
|
// Warm reconnect first: a sandbox that already solved this domain's bot check
|
|
256
291
|
// keeps its IP / UA / cleared cookies — reusing it skips boot + re-solve.
|
|
257
|
-
|
|
292
|
+
// Never warm-reconnect when proxying: the warm sandbox egresses direct.
|
|
293
|
+
if (opts.domain && !opts.proxy) {
|
|
258
294
|
const warm = await startWarmSandboxBrowser(opts.domain).catch(() => null);
|
|
259
295
|
if (warm)
|
|
260
296
|
return warm;
|
|
@@ -263,6 +299,7 @@ export async function startBrowser(opts = {}) {
|
|
|
263
299
|
// Long VM timeout headroom so the sandbox can stay warm after this call.
|
|
264
300
|
timeoutMs: Math.max(opts.timeoutMs ?? 5 * 60_000, WARM_SANDBOX_TIMEOUT_MS),
|
|
265
301
|
userAgent: opts.userAgent,
|
|
302
|
+
proxy: opts.proxy,
|
|
266
303
|
});
|
|
267
304
|
}
|
|
268
305
|
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";
|
package/dist/core/interact/vm.js
CHANGED
|
@@ -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,37 @@ 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
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
+
// Anti-bot sensor/challenge hosts are ALWAYS allowed, before any block
|
|
192
|
+
// rule: blocking them would make the DataDome / Cloudflare / AWS WAF
|
|
193
|
+
// challenge unsolvable — no cookie is minted, the interstitial loops
|
|
194
|
+
// forever, and the failed sensor POST is itself a bot signal. The
|
|
195
|
+
// sensor payload is a few KB of form-encoded text, so allowing it
|
|
196
|
+
// costs essentially nothing against the byte budget.
|
|
197
|
+
const allowRe = /(api-js\.datadome\.co|js\.datadome\.co|ct\.captcha-delivery\.com|geo\.captcha-delivery\.com|challenges\.cloudflare\.com|[a-z0-9-]+\.awswaf\.com)/i;
|
|
198
|
+
let proxyBilled = 0;
|
|
199
|
+
if (action.lightweight || action.proxyUser) {
|
|
200
|
+
const skip = new Set(action.proxyUser ? ["image", "media", "font", "stylesheet"] : ["image", "media", "font"]);
|
|
201
|
+
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;
|
|
202
|
+
const cap = action.proxyUser ? Math.max(2_000_000, Math.min(Number(action.proxyByteCap) || 6_000_000, 12_000_000)) : Infinity;
|
|
203
|
+
page.on("response", (r) => { try { proxyBilled += Number(r.headers()["content-length"] || 0) || 0; } catch {} });
|
|
204
|
+
await page.route("**/*", (r) => {
|
|
205
|
+
try {
|
|
206
|
+
const req = r.request();
|
|
207
|
+
if (allowRe.test(req.url())) return r.continue(); // never block a challenge sensor, even past the cap
|
|
208
|
+
if (proxyBilled > cap) return r.abort();
|
|
209
|
+
if (skip.has(req.resourceType())) return r.abort();
|
|
210
|
+
if (adRe.test(req.url())) return r.abort();
|
|
211
|
+
return r.continue();
|
|
212
|
+
} catch { return r.continue(); }
|
|
213
|
+
}).catch(() => {});
|
|
214
|
+
if (action.proxyUser) {
|
|
215
|
+
try { await page.authenticate({ username: String(action.proxyUser), password: String(action.proxyPass || "") }); } catch {}
|
|
216
|
+
}
|
|
184
217
|
}
|
|
185
218
|
let navStatus = null;
|
|
186
219
|
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"
|
|
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
|
},
|
package/dist/core/sandbox.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
3
|
+
"version": "1.3.3",
|
|
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.
|
|
131
|
-
"@webcrawl/curl-impersonate": "^1.3.
|
|
130
|
+
"@webcrawl/client": "^1.3.3",
|
|
131
|
+
"@webcrawl/curl-impersonate": "^1.3.3",
|
|
132
132
|
"ai": "^7.0.150",
|
|
133
133
|
"impit": "^0.14.7",
|
|
134
134
|
"mammoth": "^1.13.0",
|