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.
- package/dist/core/engine/fetcher.d.ts +10 -2
- package/dist/core/engine/fetcher.js +120 -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 +1 -0
- package/dist/core/interact/driver.js +5 -3
- package/dist/core/interact/vm.d.ts +1 -0
- package/dist/core/interact/vm.js +28 -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
|
@@ -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
|
-
|
|
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
|
-
|
|
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";
|
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,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
|
-
|
|
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
|
+
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"
|
|
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.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.
|
|
131
|
-
"@webcrawl/curl-impersonate": "^1.3.
|
|
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",
|