webcrawl 1.2.8 → 1.3.0
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 +117 -4
- package/dist/core/engine/index.d.ts +1 -0
- package/dist/core/engine/index.js +1 -0
- package/dist/core/engine/session.d.ts +69 -0
- package/dist/core/engine/session.js +156 -0
- package/dist/core/engine/stealth.d.ts +2 -0
- package/dist/core/engine/stealth.js +55 -4
- package/dist/core/interact/driver.d.ts +15 -2
- package/dist/core/interact/driver.js +86 -3
- package/dist/core/interact/vm.js +10 -1
- package/dist/core/local-engine.js +2 -2
- package/dist/core/sandbox.js +77 -57
- package/dist/core/targets/types.d.ts +1 -1
- package/package.json +3 -2
|
@@ -17,12 +17,18 @@ 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
|
}
|
|
24
24
|
export declare function getBrowserHeaders(targetUrl: string, extraHeaders?: Record<string, string>): Record<string, string>;
|
|
25
25
|
export declare function setDomainCookie(hostname: string, cookieHeader: string): void;
|
|
26
|
+
/**
|
|
27
|
+
* User-Agent paired with a domain's replayed session cookies. Cloudflare's
|
|
28
|
+
* `cf_clearance` (and similar bound tokens) only validate when the exact UA
|
|
29
|
+
* that solved the challenge is reused, so replay sets both together.
|
|
30
|
+
*/
|
|
31
|
+
export declare function setDomainUserAgent(hostname: string, userAgent: string): void;
|
|
26
32
|
/**
|
|
27
33
|
* True when an HTML response (with optional status) is an AWS WAF challenge
|
|
28
34
|
* interstitial: a marker match proves it regardless of status; otherwise a
|
|
@@ -31,8 +37,10 @@ export declare function setDomainCookie(hostname: string, cookieHeader: string):
|
|
|
31
37
|
*/
|
|
32
38
|
export declare function isAwsWafChallenge(html: string, status?: number): boolean;
|
|
33
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";
|
|
34
42
|
/**
|
|
35
|
-
* 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)
|
|
36
44
|
* so Cloudflare Bot Management and Vercel Security Checkpoint treat requests as real Chrome.
|
|
37
45
|
* Falls back to standard `fetch` if `impit` is unavailable on the current platform.
|
|
38
46
|
* Every redirect hop is checked against the SSRF guard.
|
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
import { followSafely, UnsafeUrlError } from "./safe-url.js";
|
|
2
2
|
const domainCookies = new Map();
|
|
3
|
+
const domainUserAgents = new Map();
|
|
3
4
|
const BROWSER_USER_AGENTS = [
|
|
4
5
|
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36",
|
|
5
6
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36",
|
|
6
7
|
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36",
|
|
7
8
|
];
|
|
8
9
|
export function getBrowserHeaders(targetUrl, extraHeaders) {
|
|
9
|
-
const
|
|
10
|
+
const parsed = new URL(targetUrl);
|
|
11
|
+
const ua = domainUserAgents.get(parsed.hostname) ?? BROWSER_USER_AGENTS[Math.abs(hashCode(targetUrl)) % BROWSER_USER_AGENTS.length];
|
|
10
12
|
let hostCookie;
|
|
11
13
|
try {
|
|
12
|
-
const parsed = new URL(targetUrl);
|
|
13
14
|
hostCookie = domainCookies.get(parsed.hostname);
|
|
14
15
|
}
|
|
15
16
|
catch {
|
|
@@ -39,6 +40,16 @@ export function setDomainCookie(hostname, cookieHeader) {
|
|
|
39
40
|
domainCookies.set(hostname, cookieHeader);
|
|
40
41
|
}
|
|
41
42
|
}
|
|
43
|
+
/**
|
|
44
|
+
* User-Agent paired with a domain's replayed session cookies. Cloudflare's
|
|
45
|
+
* `cf_clearance` (and similar bound tokens) only validate when the exact UA
|
|
46
|
+
* that solved the challenge is reused, so replay sets both together.
|
|
47
|
+
*/
|
|
48
|
+
export function setDomainUserAgent(hostname, userAgent) {
|
|
49
|
+
if (hostname && userAgent) {
|
|
50
|
+
domainUserAgents.set(hostname, userAgent);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
42
53
|
function hashCode(str) {
|
|
43
54
|
let hash = 0;
|
|
44
55
|
for (let i = 0; i < str.length; i++) {
|
|
@@ -98,6 +109,12 @@ export function isAntiBotChallenge(html, status) {
|
|
|
98
109
|
return isAwsWafChallenge(html, status);
|
|
99
110
|
}
|
|
100
111
|
let sharedImpit = null;
|
|
112
|
+
/** Tier that won the most recent rawGet ladder ("impit-tls" → "impersonate" → "fetch"). */
|
|
113
|
+
let lastTier = "impit-tls";
|
|
114
|
+
/** Transport tier that won the last fetch ladder run (for result evidence). */
|
|
115
|
+
export function lastFetchTier() {
|
|
116
|
+
return lastTier;
|
|
117
|
+
}
|
|
101
118
|
/** One request, redirects NOT followed (callers validate each hop via followSafely). */
|
|
102
119
|
async function rawGet(url, reqHeaders, timeoutMs, maxBytes) {
|
|
103
120
|
const read = async (status, headers, getBuf) => {
|
|
@@ -121,26 +138,122 @@ async function rawGet(url, reqHeaders, timeoutMs, maxBytes) {
|
|
|
121
138
|
timeout: timeoutMs,
|
|
122
139
|
redirect: "manual",
|
|
123
140
|
});
|
|
124
|
-
|
|
141
|
+
const hop = await read(res.status, headersToObject(res.headers), () => res.arrayBuffer());
|
|
142
|
+
// AWS-WAF-style blocks (403/429/challenge shells) on the impit tier
|
|
143
|
+
// (Chrome 136) retry once on the curl-impersonate tier (Chrome 150),
|
|
144
|
+
// which passes WAFs that block the older fingerprint.
|
|
145
|
+
if (isAntiBotChallenge(new TextDecoder().decode(hop.body).slice(0, 8000), hop.status)) {
|
|
146
|
+
const impersonateHop = await rawGetImpersonate(url, reqHeaders, timeoutMs, maxBytes);
|
|
147
|
+
if (impersonateHop) {
|
|
148
|
+
lastTier = "impersonate";
|
|
149
|
+
return impersonateHop;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
lastTier = "impit-tls";
|
|
153
|
+
return hop;
|
|
125
154
|
}
|
|
126
155
|
catch (err) {
|
|
127
156
|
if (err instanceof ResourceTooLargeError || err instanceof UnsafeUrlError)
|
|
128
157
|
throw err;
|
|
158
|
+
// impit unavailable/failed: curl-impersonate tier, then plain fetch.
|
|
159
|
+
const impersonateHop = await rawGetImpersonate(url, reqHeaders, timeoutMs, maxBytes);
|
|
160
|
+
if (impersonateHop) {
|
|
161
|
+
lastTier = "impersonate";
|
|
162
|
+
return impersonateHop;
|
|
163
|
+
}
|
|
129
164
|
const res = await fetch(url, {
|
|
130
165
|
headers: reqHeaders,
|
|
131
166
|
redirect: "manual",
|
|
132
167
|
signal: AbortSignal.timeout(timeoutMs),
|
|
133
168
|
});
|
|
169
|
+
lastTier = "fetch";
|
|
134
170
|
return await read(res.status, headersToObject(res.headers), () => res.arrayBuffer());
|
|
135
171
|
}
|
|
136
172
|
}
|
|
137
173
|
/**
|
|
138
|
-
*
|
|
174
|
+
* One GET hop with the curl-impersonate tier (Chrome 150 TLS/HTTP2 fingerprint).
|
|
175
|
+
* Used by `rawGet` when the impit hop is blocked (403/429/challenge) — newer
|
|
176
|
+
* fingerprints pass AWS WAF domains (stubhub, expedia) that block Chrome 136.
|
|
177
|
+
* Falls back to plain `fetch` when the binary is unavailable.
|
|
178
|
+
*/
|
|
179
|
+
async function rawGetImpersonate(url, reqHeaders, timeoutMs, maxBytes) {
|
|
180
|
+
let impersonate;
|
|
181
|
+
try {
|
|
182
|
+
impersonate = await import("@webcrawl/curl-impersonate");
|
|
183
|
+
}
|
|
184
|
+
catch {
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
// The chrome150 profile's own identity headers MUST survive (profileHeaders
|
|
188
|
+
// merges extras last, so a stale UA would override the profile UA and the
|
|
189
|
+
// UA/TLS mismatch is exactly what DataDome/AWS WAF block). Keep cookies and
|
|
190
|
+
// referer, drop UA + sec-ch/client-hint identity headers.
|
|
191
|
+
const extra = {};
|
|
192
|
+
for (const [key, value] of Object.entries(reqHeaders)) {
|
|
193
|
+
const lower = key.toLowerCase();
|
|
194
|
+
if (lower === "user-agent" ||
|
|
195
|
+
lower.startsWith("sec-ch-ua") ||
|
|
196
|
+
lower.startsWith("sec-fetch-") ||
|
|
197
|
+
lower === "upgrade-insecure-requests" ||
|
|
198
|
+
lower === "priority") {
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
extra[key] = value;
|
|
202
|
+
}
|
|
203
|
+
try {
|
|
204
|
+
const res = await impersonate.impersonateFetch(url, {
|
|
205
|
+
headers: extra,
|
|
206
|
+
timeoutMs,
|
|
207
|
+
maxBytes,
|
|
208
|
+
});
|
|
209
|
+
const headers = res.headers;
|
|
210
|
+
const isRedirect = res.status >= 300 && res.status < 400 && !!headers["location"];
|
|
211
|
+
if (!isRedirect && maxBytes !== undefined)
|
|
212
|
+
checkDeclaredLength(headers, maxBytes);
|
|
213
|
+
return {
|
|
214
|
+
status: res.status,
|
|
215
|
+
headers,
|
|
216
|
+
body: isRedirect ? new ArrayBuffer(0) : res.body.buffer,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
catch (err) {
|
|
220
|
+
if (err instanceof UnsafeUrlError)
|
|
221
|
+
throw err;
|
|
222
|
+
return null; // tier unavailable or fetch failed — caller falls back
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Fetches a URL using `impit` (BoringSSL Chrome 136 TLS JA3/JA4 + HTTP/2 fingerprint impersonation)sonation)
|
|
139
227
|
* so Cloudflare Bot Management and Vercel Security Checkpoint treat requests as real Chrome.
|
|
140
228
|
* Falls back to standard `fetch` if `impit` is unavailable on the current platform.
|
|
141
229
|
* Every redirect hop is checked against the SSRF guard.
|
|
142
230
|
*/
|
|
143
231
|
export async function fetchWithTlsImpersonation(url, timeoutMs = 12_000) {
|
|
232
|
+
// Session hydration: when a warm stealth session exists for this domain, its
|
|
233
|
+
// solved cookies (and the UA they were solved under — bound cookies require
|
|
234
|
+
// the pair) ride the request so the lightweight tier can skip the challenge.
|
|
235
|
+
let hostname = "";
|
|
236
|
+
try {
|
|
237
|
+
hostname = new URL(url).hostname;
|
|
238
|
+
}
|
|
239
|
+
catch {
|
|
240
|
+
/* invalid URL handled below */
|
|
241
|
+
}
|
|
242
|
+
if (hostname) {
|
|
243
|
+
try {
|
|
244
|
+
const { getWarmSession } = await import("./session.js");
|
|
245
|
+
const warm = await getWarmSession(hostname);
|
|
246
|
+
if (warm) {
|
|
247
|
+
if (warm.cookieHeader)
|
|
248
|
+
setDomainCookie(hostname, warm.cookieHeader);
|
|
249
|
+
if (warm.userAgent)
|
|
250
|
+
setDomainUserAgent(hostname, warm.userAgent);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
catch {
|
|
254
|
+
/* session module unavailable — plain fetch */
|
|
255
|
+
}
|
|
256
|
+
}
|
|
144
257
|
const { res, finalUrl } = await followSafely(url, (hop) => rawGet(hop, getBrowserHeaders(hop), timeoutMs));
|
|
145
258
|
if (res.headers["set-cookie"]) {
|
|
146
259
|
try {
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stealth session reuse: warm-sandbox registry, cookie-replay state and the
|
|
3
|
+
* JS-challenge memo.
|
|
4
|
+
*
|
|
5
|
+
* A solved bot check (AWS WAF, Cloudflare, Vercel checkpoint) leaves behind a
|
|
6
|
+
* valuable session: the sandbox's egress IP, its User-Agent and the cleared
|
|
7
|
+
* cookies. Instead of throwing that away with the sandbox, this module keeps:
|
|
8
|
+
* - a warm registry (domain -> sandboxId + UA + cookies) so the next query
|
|
9
|
+
* reconnects to the still-running sandbox (`Sandbox.get`) instead of
|
|
10
|
+
* cold-booting and re-solving;
|
|
11
|
+
* - the cookie header for replay on the lightweight TLS-impersonated tier
|
|
12
|
+
* (works only where the provider does not bind tokens to the solver IP —
|
|
13
|
+
* Cloudflare's `cf_clearance` does; AWS WAF's 300 s immunity may not);
|
|
14
|
+
* - a memo of domains known to serve JS challenges, so the useless
|
|
15
|
+
* curl-sandbox tier is skipped for them.
|
|
16
|
+
*
|
|
17
|
+
* Everything is in-memory by default; `registerExternalSessionStore` plugs in
|
|
18
|
+
* persistence (Postgres in the hosted app) so warm sessions survive across
|
|
19
|
+
* serverless instances. A registered store is always optional: every call
|
|
20
|
+
* degrades gracefully when it is unavailable.
|
|
21
|
+
*/
|
|
22
|
+
/** A solved stealth session kept for reuse. */
|
|
23
|
+
export interface WarmSession {
|
|
24
|
+
domain: string;
|
|
25
|
+
sandboxId: string;
|
|
26
|
+
userAgent: string;
|
|
27
|
+
/** `Cookie:` header value for the solved session (UA-paired: replay both or neither). */
|
|
28
|
+
cookieHeader: string;
|
|
29
|
+
/** Epoch ms after which the entry is stale. */
|
|
30
|
+
expiresAt: number;
|
|
31
|
+
}
|
|
32
|
+
export interface ExternalSessionStore {
|
|
33
|
+
getWarmSession(domain: string): Promise<WarmSession | null>;
|
|
34
|
+
saveWarmSession(session: WarmSession): Promise<void>;
|
|
35
|
+
deleteWarmSession(domain: string): Promise<void>;
|
|
36
|
+
markDomainChallenged(domain: string): Promise<void>;
|
|
37
|
+
isDomainChallenged(domain: string): Promise<boolean>;
|
|
38
|
+
}
|
|
39
|
+
/** How long a solved session is offered for warm reconnect / cookie replay. */
|
|
40
|
+
export declare const WARM_TTL_MS: number;
|
|
41
|
+
/** VM `timeout` headroom set at Sandbox.create so a warm sandbox outlives its registry TTL. */
|
|
42
|
+
export declare const WARM_SANDBOX_TIMEOUT_MS: number;
|
|
43
|
+
/** Cap on concurrently warm sandboxes; LRU entries beyond the cap are evicted. */
|
|
44
|
+
export declare const WARM_CAP = 4;
|
|
45
|
+
/** How long a domain stays on the JS-challenge memo. */
|
|
46
|
+
export declare const CHALLENGE_MEMO_TTL_MS: number;
|
|
47
|
+
export declare function registerExternalSessionStore(store: ExternalSessionStore): void;
|
|
48
|
+
export interface CookieLike {
|
|
49
|
+
name: string;
|
|
50
|
+
value: string;
|
|
51
|
+
domain?: string;
|
|
52
|
+
path?: string;
|
|
53
|
+
expires?: number;
|
|
54
|
+
}
|
|
55
|
+
/** Serializes captured browser cookies into a `Cookie:` header value (drops expired). */
|
|
56
|
+
export declare function cookieHeaderFrom(cookies: CookieLike[]): string;
|
|
57
|
+
/** Best-available warm session for a domain (in-memory first, then the external store). */
|
|
58
|
+
export declare function getWarmSession(domain: string): Promise<WarmSession | null>;
|
|
59
|
+
/**
|
|
60
|
+
* Registers a solved session as warm. Returns the sandboxIds evicted by the
|
|
61
|
+
* cap — the caller must stop those sandboxes so idle compute does not linger.
|
|
62
|
+
*/
|
|
63
|
+
export declare function saveWarmSession(session: WarmSession): Promise<string[]>;
|
|
64
|
+
export declare function deleteWarmSession(domain: string): Promise<void>;
|
|
65
|
+
/** Marks a domain as known to serve JS challenges (skips the curl tier next time). */
|
|
66
|
+
export declare function markDomainChallenged(domain: string): Promise<void>;
|
|
67
|
+
export declare function isDomainChallenged(domain: string): Promise<boolean>;
|
|
68
|
+
/** Test helper: resets all in-memory state (external stores are untouched). */
|
|
69
|
+
export declare function resetSessionStateForTests(): void;
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stealth session reuse: warm-sandbox registry, cookie-replay state and the
|
|
3
|
+
* JS-challenge memo.
|
|
4
|
+
*
|
|
5
|
+
* A solved bot check (AWS WAF, Cloudflare, Vercel checkpoint) leaves behind a
|
|
6
|
+
* valuable session: the sandbox's egress IP, its User-Agent and the cleared
|
|
7
|
+
* cookies. Instead of throwing that away with the sandbox, this module keeps:
|
|
8
|
+
* - a warm registry (domain -> sandboxId + UA + cookies) so the next query
|
|
9
|
+
* reconnects to the still-running sandbox (`Sandbox.get`) instead of
|
|
10
|
+
* cold-booting and re-solving;
|
|
11
|
+
* - the cookie header for replay on the lightweight TLS-impersonated tier
|
|
12
|
+
* (works only where the provider does not bind tokens to the solver IP —
|
|
13
|
+
* Cloudflare's `cf_clearance` does; AWS WAF's 300 s immunity may not);
|
|
14
|
+
* - a memo of domains known to serve JS challenges, so the useless
|
|
15
|
+
* curl-sandbox tier is skipped for them.
|
|
16
|
+
*
|
|
17
|
+
* Everything is in-memory by default; `registerExternalSessionStore` plugs in
|
|
18
|
+
* persistence (Postgres in the hosted app) so warm sessions survive across
|
|
19
|
+
* serverless instances. A registered store is always optional: every call
|
|
20
|
+
* degrades gracefully when it is unavailable.
|
|
21
|
+
*/
|
|
22
|
+
/** How long a solved session is offered for warm reconnect / cookie replay. */
|
|
23
|
+
export const WARM_TTL_MS = 8 * 60_000;
|
|
24
|
+
/** VM `timeout` headroom set at Sandbox.create so a warm sandbox outlives its registry TTL. */
|
|
25
|
+
export const WARM_SANDBOX_TIMEOUT_MS = 10 * 60_000;
|
|
26
|
+
/** Cap on concurrently warm sandboxes; LRU entries beyond the cap are evicted. */
|
|
27
|
+
export const WARM_CAP = 4;
|
|
28
|
+
/** How long a domain stays on the JS-challenge memo. */
|
|
29
|
+
export const CHALLENGE_MEMO_TTL_MS = 30 * 60_000;
|
|
30
|
+
let external = null;
|
|
31
|
+
export function registerExternalSessionStore(store) {
|
|
32
|
+
external = store;
|
|
33
|
+
}
|
|
34
|
+
const warm = new Map();
|
|
35
|
+
const challenged = new Map();
|
|
36
|
+
function reap(now = Date.now()) {
|
|
37
|
+
for (const [d, s] of warm)
|
|
38
|
+
if (s.expiresAt <= now)
|
|
39
|
+
warm.delete(d);
|
|
40
|
+
for (const [d, t] of challenged)
|
|
41
|
+
if (t <= now)
|
|
42
|
+
challenged.delete(d);
|
|
43
|
+
}
|
|
44
|
+
/** Serializes captured browser cookies into a `Cookie:` header value (drops expired). */
|
|
45
|
+
export function cookieHeaderFrom(cookies) {
|
|
46
|
+
const now = Date.now() / 1000;
|
|
47
|
+
return cookies
|
|
48
|
+
.filter((c) => c.name && c.value && (!c.expires || c.expires > now))
|
|
49
|
+
.map((c) => `${c.name}=${c.value}`)
|
|
50
|
+
.join("; ");
|
|
51
|
+
}
|
|
52
|
+
/** Best-available warm session for a domain (in-memory first, then the external store). */
|
|
53
|
+
export async function getWarmSession(domain) {
|
|
54
|
+
if (!domain)
|
|
55
|
+
return null;
|
|
56
|
+
reap();
|
|
57
|
+
const local = warm.get(domain);
|
|
58
|
+
if (local && local.expiresAt > Date.now())
|
|
59
|
+
return local;
|
|
60
|
+
if (local)
|
|
61
|
+
warm.delete(domain);
|
|
62
|
+
if (external) {
|
|
63
|
+
try {
|
|
64
|
+
const stored = await external.getWarmSession(domain);
|
|
65
|
+
if (stored && stored.expiresAt > Date.now()) {
|
|
66
|
+
warm.set(domain, stored);
|
|
67
|
+
return stored;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
/* store unavailable */
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Registers a solved session as warm. Returns the sandboxIds evicted by the
|
|
78
|
+
* cap — the caller must stop those sandboxes so idle compute does not linger.
|
|
79
|
+
*/
|
|
80
|
+
export async function saveWarmSession(session) {
|
|
81
|
+
if (!session.domain)
|
|
82
|
+
return [];
|
|
83
|
+
reap();
|
|
84
|
+
warm.delete(session.domain);
|
|
85
|
+
warm.set(session.domain, { ...session });
|
|
86
|
+
const evicted = [];
|
|
87
|
+
while (warm.size > WARM_CAP) {
|
|
88
|
+
const [d, s] = warm.entries().next().value;
|
|
89
|
+
warm.delete(d);
|
|
90
|
+
if (s.sandboxId && s.sandboxId !== session.sandboxId)
|
|
91
|
+
evicted.push(s.sandboxId);
|
|
92
|
+
}
|
|
93
|
+
if (external) {
|
|
94
|
+
try {
|
|
95
|
+
await external.saveWarmSession(session);
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
/* store unavailable */
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return evicted;
|
|
102
|
+
}
|
|
103
|
+
export async function deleteWarmSession(domain) {
|
|
104
|
+
if (!domain)
|
|
105
|
+
return;
|
|
106
|
+
warm.delete(domain);
|
|
107
|
+
if (external) {
|
|
108
|
+
try {
|
|
109
|
+
await external.deleteWarmSession(domain);
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
/* store unavailable */
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
/** Marks a domain as known to serve JS challenges (skips the curl tier next time). */
|
|
117
|
+
export async function markDomainChallenged(domain) {
|
|
118
|
+
if (!domain)
|
|
119
|
+
return;
|
|
120
|
+
challenged.set(domain, Date.now() + CHALLENGE_MEMO_TTL_MS);
|
|
121
|
+
if (external) {
|
|
122
|
+
try {
|
|
123
|
+
await external.markDomainChallenged(domain);
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
/* store unavailable */
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
export async function isDomainChallenged(domain) {
|
|
131
|
+
if (!domain)
|
|
132
|
+
return false;
|
|
133
|
+
reap();
|
|
134
|
+
const until = challenged.get(domain);
|
|
135
|
+
if (until && until > Date.now())
|
|
136
|
+
return true;
|
|
137
|
+
if (until)
|
|
138
|
+
challenged.delete(domain);
|
|
139
|
+
if (external) {
|
|
140
|
+
try {
|
|
141
|
+
if (await external.isDomainChallenged(domain)) {
|
|
142
|
+
challenged.set(domain, Date.now() + CHALLENGE_MEMO_TTL_MS);
|
|
143
|
+
return true;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
/* store unavailable */
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
152
|
+
/** Test helper: resets all in-memory state (external stores are untouched). */
|
|
153
|
+
export function resetSessionStateForTests() {
|
|
154
|
+
warm.clear();
|
|
155
|
+
challenged.clear();
|
|
156
|
+
}
|
|
@@ -45,6 +45,8 @@ export declare function stealthFetch(entryUrl: string, items: StealthItem[], opt
|
|
|
45
45
|
followSitemaps?: number;
|
|
46
46
|
/** Allow the paid remote provider. Off for unbilled (anonymous) runs. Default true. */
|
|
47
47
|
remote?: boolean;
|
|
48
|
+
/** Keep the solved session warm (sandbox left running + cookies saved) for repeat queries. */
|
|
49
|
+
keepWarm?: boolean;
|
|
48
50
|
}): Promise<StealthResult>;
|
|
49
51
|
/** Extracts `<loc>` entries from a sitemap or sitemap index. */
|
|
50
52
|
export declare function parseSitemapLocs(xml: string): {
|
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
* Never fabricates content: a page still showing a check comes back `blocked`.
|
|
11
11
|
*/
|
|
12
12
|
import { isAntiBotChallenge } from "./fetcher.js";
|
|
13
|
+
import { setDomainCookie, setDomainUserAgent } from "./fetcher.js";
|
|
14
|
+
import { cookieHeaderFrom, getWarmSession, saveWarmSession, WARM_TTL_MS } from "./session.js";
|
|
13
15
|
import { remoteStealthProvider } from "../interact/remote.js";
|
|
14
16
|
/**
|
|
15
17
|
* True when a real browser can run here: our own (sandbox in prod, local
|
|
@@ -37,19 +39,29 @@ export async function stealthFetch(entryUrl, items, opts = {}) {
|
|
|
37
39
|
}
|
|
38
40
|
const { startBrowser, runBrowserStep, stopBrowser } = await import("../interact/driver.js");
|
|
39
41
|
const provider = opts.remote === false ? null : remoteStealthProvider();
|
|
40
|
-
const
|
|
42
|
+
const domain = hostOf(entryUrl);
|
|
43
|
+
// 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);
|
|
46
|
+
const tag = provider ? { provider } : { warm: Boolean(hadWarm) };
|
|
41
47
|
let handle = null;
|
|
42
48
|
// Filled in by `finally` once the provider reports the session cost.
|
|
43
49
|
const out = {};
|
|
50
|
+
let capturedCookies = [];
|
|
51
|
+
let capturedUa = "";
|
|
44
52
|
try {
|
|
45
53
|
const budgetMs = opts.budgetMs ?? 90_000;
|
|
46
|
-
handle = await startBrowser({
|
|
54
|
+
handle = await startBrowser({
|
|
55
|
+
timeoutMs: Math.min(5 * 60_000, budgetMs + 90_000),
|
|
56
|
+
remote: provider,
|
|
57
|
+
domain: provider ? undefined : domain,
|
|
58
|
+
});
|
|
47
59
|
const res = await runBrowserStep(handle, {
|
|
48
60
|
type: "fetchMany",
|
|
49
61
|
entry: entryUrl,
|
|
50
62
|
items: items.slice(0, 40),
|
|
51
63
|
budgetMs,
|
|
52
|
-
solveMs: opts.solveMs ?? 20_000,
|
|
64
|
+
solveMs: hadWarm ? Math.min(opts.solveMs ?? 20_000, 8_000) : (opts.solveMs ?? 20_000),
|
|
53
65
|
followSitemaps: opts.followSitemaps ?? 0,
|
|
54
66
|
});
|
|
55
67
|
if (!res.ok) {
|
|
@@ -57,6 +69,13 @@ export async function stealthFetch(entryUrl, items, opts = {}) {
|
|
|
57
69
|
}
|
|
58
70
|
else {
|
|
59
71
|
const value = (res.data?.value ?? {});
|
|
72
|
+
capturedCookies = Array.isArray(value.cookies) ? value.cookies : [];
|
|
73
|
+
capturedUa = value.userAgent ?? "";
|
|
74
|
+
if (capturedUa)
|
|
75
|
+
setDomainUserAgent(domain, capturedUa);
|
|
76
|
+
if (capturedCookies.length > 0) {
|
|
77
|
+
setDomainCookie(domain, cookieHeaderFrom(capturedCookies));
|
|
78
|
+
}
|
|
60
79
|
out.result = {
|
|
61
80
|
solved: Boolean(value.solved),
|
|
62
81
|
entry: value.entry ?? null,
|
|
@@ -70,9 +89,32 @@ export async function stealthFetch(entryUrl, items, opts = {}) {
|
|
|
70
89
|
out.result = { solved: false, entry: null, docs: [], durationMs: 0, error: err instanceof Error ? err.message : String(err), ...tag };
|
|
71
90
|
}
|
|
72
91
|
finally {
|
|
73
|
-
const
|
|
92
|
+
const solved = out.result?.solved === true;
|
|
93
|
+
const keepWarm = !provider && solved && (opts.keepWarm ?? true) && Boolean(handle && handle.kind === "sandbox" && capturedUa);
|
|
94
|
+
const cost = handle
|
|
95
|
+
? await stopBrowser(handle, {
|
|
96
|
+
keepWarm,
|
|
97
|
+
domain,
|
|
98
|
+
userAgent: capturedUa,
|
|
99
|
+
cookieHeader: capturedCookies.length > 0 ? cookieHeaderFrom(capturedCookies) : "",
|
|
100
|
+
}).catch(() => null)
|
|
101
|
+
: null;
|
|
74
102
|
if (out.result && cost)
|
|
75
103
|
out.result.providerCostUsd = cost.costUsd;
|
|
104
|
+
if (!keepWarm && solved && capturedCookies.length > 0) {
|
|
105
|
+
// Even without a warm sandbox, remember the solved cookies for replay on
|
|
106
|
+
// the lightweight tier (UA-paired; rejection there just escalates).
|
|
107
|
+
await saveWarmSession({ domain, sandboxId: "", userAgent: capturedUa, cookieHeader: cookieHeaderFrom(capturedCookies), expiresAt: Date.now() + WARM_TTL_MS })
|
|
108
|
+
.catch(() => { });
|
|
109
|
+
}
|
|
110
|
+
console.info("[stealth] session result", {
|
|
111
|
+
domain,
|
|
112
|
+
solved,
|
|
113
|
+
warm: Boolean(hadWarm),
|
|
114
|
+
keepWarm,
|
|
115
|
+
cookieCount: capturedCookies.length,
|
|
116
|
+
durationMs: Date.now() - t0,
|
|
117
|
+
});
|
|
76
118
|
}
|
|
77
119
|
const result = out.result;
|
|
78
120
|
result.durationMs = Date.now() - t0;
|
|
@@ -88,6 +130,15 @@ export async function stealthFetch(entryUrl, items, opts = {}) {
|
|
|
88
130
|
}
|
|
89
131
|
return result;
|
|
90
132
|
}
|
|
133
|
+
/** Hostname of a URL, or "" when unparseable. */
|
|
134
|
+
function hostOf(url) {
|
|
135
|
+
try {
|
|
136
|
+
return new URL(url).hostname;
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
return "";
|
|
140
|
+
}
|
|
141
|
+
}
|
|
91
142
|
/** Extracts `<loc>` entries from a sitemap or sitemap index. */
|
|
92
143
|
export function parseSitemapLocs(xml) {
|
|
93
144
|
const pages = [];
|
|
@@ -42,8 +42,21 @@ export declare function startBrowser(opts?: {
|
|
|
42
42
|
timeoutMs?: number;
|
|
43
43
|
userAgent?: string;
|
|
44
44
|
remote?: RemoteProvider | null;
|
|
45
|
+
domain?: string;
|
|
45
46
|
}): Promise<BrowserHandle>;
|
|
46
47
|
export declare function runBrowserStep(handle: BrowserHandle, action: StepAction): Promise<StepResultLine>;
|
|
47
|
-
/**
|
|
48
|
-
|
|
48
|
+
/**
|
|
49
|
+
* Stops the browser. For a remote provider, returns what the session cost (null
|
|
50
|
+
* when unknown). With `keepWarm`, a sandbox browser is left running and
|
|
51
|
+
* registered (domain + sandboxId + UA) so the next call reconnects to it via
|
|
52
|
+
* `Sandbox.get` instead of cold-booting — the caller must pass `domain`.
|
|
53
|
+
* Evicted warm entries (over the cap) are stopped here so idle compute
|
|
54
|
+
* does not linger.
|
|
55
|
+
*/
|
|
56
|
+
export declare function stopBrowser(handle: BrowserHandle, opts?: {
|
|
57
|
+
keepWarm?: boolean;
|
|
58
|
+
domain?: string;
|
|
59
|
+
userAgent?: string;
|
|
60
|
+
cookieHeader?: string;
|
|
61
|
+
}): Promise<RemoteSessionCost | null>;
|
|
49
62
|
export { CDP_PORT };
|
|
@@ -15,6 +15,7 @@ import path from "node:path";
|
|
|
15
15
|
import { Sandbox } from "@vercel/sandbox";
|
|
16
16
|
import { CDP_PORT, SETUP_SCRIPT, STEP_SCRIPT, STEP_SCRIPT_PATH, VM_ROOT, WAIT_FOR_CDP_SCRIPT, chromeArgs, parseStepOutput, } from "./vm.js";
|
|
17
17
|
import { startRemoteSession, stopRemoteSession } from "./remote.js";
|
|
18
|
+
import { WARM_SANDBOX_TIMEOUT_MS, WARM_TTL_MS } from "../engine/session.js";
|
|
18
19
|
const snapshotId = () => process.env.WEBCRAWL_INTERACT_SNAPSHOT_ID || process.env.INTERACT_SNAPSHOT_ID || "";
|
|
19
20
|
const localMode = () => process.env.INTERACT_LOCAL_PLAYWRIGHT === "true";
|
|
20
21
|
/**
|
|
@@ -91,6 +92,44 @@ async function startSandboxBrowser(opts) {
|
|
|
91
92
|
throw err;
|
|
92
93
|
}
|
|
93
94
|
}
|
|
95
|
+
/** True when the sandbox's Chromium still answers on CDP (warm reconnect check). */
|
|
96
|
+
async function sandboxChromeAlive(sandbox) {
|
|
97
|
+
const ping = await sh(sandbox, `curl -sf -m 3 http://127.0.0.1:${CDP_PORT}/json/version >/dev/null && echo alive`, false);
|
|
98
|
+
return ping.exitCode === 0 && ping.stdout.includes("alive");
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Warm reconnect: fetch the sandbox this domain solved its challenge in, verify
|
|
102
|
+
* Chromium still lives, and reuse it (same IP, same UA, same cleared cookies).
|
|
103
|
+
* Returns null when no warm entry exists or the sandbox is gone (cold boot).
|
|
104
|
+
*/
|
|
105
|
+
async function startWarmSandboxBrowser(domain) {
|
|
106
|
+
if (!domain)
|
|
107
|
+
return null;
|
|
108
|
+
const { getWarmSession } = await import("../engine/session.js");
|
|
109
|
+
let warm;
|
|
110
|
+
try {
|
|
111
|
+
warm = await getWarmSession(domain);
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
if (!warm || !warm.sandboxId)
|
|
117
|
+
return null;
|
|
118
|
+
try {
|
|
119
|
+
const sandbox = await Sandbox.get({ sandboxId: warm.sandboxId });
|
|
120
|
+
if (!(await sandboxChromeAlive(sandbox))) {
|
|
121
|
+
await sandbox.stop().catch(() => { });
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
// Refresh the step runner in case this instance ships newer code.
|
|
125
|
+
await sandbox.writeFiles([{ path: STEP_SCRIPT_PATH, content: Buffer.from(STEP_SCRIPT) }]).catch(() => { });
|
|
126
|
+
console.info("[stealth] warm reconnect", { domain, sandboxId: warm.sandboxId });
|
|
127
|
+
return { kind: "sandbox", sandboxId: warm.sandboxId };
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
94
133
|
/* ------------------------------ local backend ----------------------------- */
|
|
95
134
|
async function resolveLocalPlaywright() {
|
|
96
135
|
const candidates = [
|
|
@@ -213,7 +252,18 @@ export async function startBrowser(opts = {}) {
|
|
|
213
252
|
return startLocalBrowser(opts);
|
|
214
253
|
if (!interactBrowserAvailable())
|
|
215
254
|
throw new Error("browser_unavailable");
|
|
216
|
-
|
|
255
|
+
// Warm reconnect first: a sandbox that already solved this domain's bot check
|
|
256
|
+
// keeps its IP / UA / cleared cookies — reusing it skips boot + re-solve.
|
|
257
|
+
if (opts.domain) {
|
|
258
|
+
const warm = await startWarmSandboxBrowser(opts.domain).catch(() => null);
|
|
259
|
+
if (warm)
|
|
260
|
+
return warm;
|
|
261
|
+
}
|
|
262
|
+
return startSandboxBrowser({
|
|
263
|
+
// Long VM timeout headroom so the sandbox can stay warm after this call.
|
|
264
|
+
timeoutMs: Math.max(opts.timeoutMs ?? 5 * 60_000, WARM_SANDBOX_TIMEOUT_MS),
|
|
265
|
+
userAgent: opts.userAgent,
|
|
266
|
+
});
|
|
217
267
|
}
|
|
218
268
|
function runLocalStep(scriptPath, playwright, cdp, action, extraEnv = {}) {
|
|
219
269
|
return new Promise((resolve) => {
|
|
@@ -243,8 +293,15 @@ export async function runBrowserStep(handle, action) {
|
|
|
243
293
|
}
|
|
244
294
|
return runSandboxStep(handle.sandboxId, action);
|
|
245
295
|
}
|
|
246
|
-
/**
|
|
247
|
-
|
|
296
|
+
/**
|
|
297
|
+
* Stops the browser. For a remote provider, returns what the session cost (null
|
|
298
|
+
* when unknown). With `keepWarm`, a sandbox browser is left running and
|
|
299
|
+
* registered (domain + sandboxId + UA) so the next call reconnects to it via
|
|
300
|
+
* `Sandbox.get` instead of cold-booting — the caller must pass `domain`.
|
|
301
|
+
* Evicted warm entries (over the cap) are stopped here so idle compute
|
|
302
|
+
* does not linger.
|
|
303
|
+
*/
|
|
304
|
+
export async function stopBrowser(handle, opts = {}) {
|
|
248
305
|
if (handle.kind === "local") {
|
|
249
306
|
try {
|
|
250
307
|
process.kill(handle.pid);
|
|
@@ -258,6 +315,32 @@ export async function stopBrowser(handle) {
|
|
|
258
315
|
const [cost] = await Promise.all([stopRemoteSession(handle), stopRunner(handle.runner)]);
|
|
259
316
|
return cost;
|
|
260
317
|
}
|
|
318
|
+
if (opts.keepWarm && opts.domain) {
|
|
319
|
+
const { saveWarmSession } = await import("../engine/session.js");
|
|
320
|
+
try {
|
|
321
|
+
const evicted = await saveWarmSession({
|
|
322
|
+
domain: opts.domain,
|
|
323
|
+
sandboxId: handle.sandboxId,
|
|
324
|
+
userAgent: opts.userAgent ?? "",
|
|
325
|
+
cookieHeader: opts.cookieHeader ?? "",
|
|
326
|
+
expiresAt: Date.now() + WARM_TTL_MS,
|
|
327
|
+
});
|
|
328
|
+
await Promise.all(evicted.map(async (id) => {
|
|
329
|
+
try {
|
|
330
|
+
const sandbox = await Sandbox.get({ sandboxId: id });
|
|
331
|
+
await sandbox.stop();
|
|
332
|
+
}
|
|
333
|
+
catch {
|
|
334
|
+
/* already stopped */
|
|
335
|
+
}
|
|
336
|
+
}));
|
|
337
|
+
console.info("[stealth] kept warm", { domain: opts.domain, sandboxId: handle.sandboxId, evicted: evicted.length });
|
|
338
|
+
return null;
|
|
339
|
+
}
|
|
340
|
+
catch {
|
|
341
|
+
/* registry unavailable — fall through to full stop */
|
|
342
|
+
}
|
|
343
|
+
}
|
|
261
344
|
await stopRunner(handle);
|
|
262
345
|
return null;
|
|
263
346
|
}
|
package/dist/core/interact/vm.js
CHANGED
|
@@ -261,7 +261,16 @@ try {
|
|
|
261
261
|
docs.push({ url, ok: false, error: String(e && e.message || e).split("\n")[0].slice(0, 300) });
|
|
262
262
|
}
|
|
263
263
|
}
|
|
264
|
-
|
|
264
|
+
// Session capture: cookies + UA ride the result so the solved session can
|
|
265
|
+
// be replayed on the lightweight tier or the sandbox kept warm + reconnected.
|
|
266
|
+
let cookies = [];
|
|
267
|
+
try {
|
|
268
|
+
cookies = (await ctx.cookies())
|
|
269
|
+
.filter((c) => !c.expires || c.expires > Date.now() / 1000)
|
|
270
|
+
.slice(0, 50)
|
|
271
|
+
.map((c) => ({ name: c.name, value: c.value, domain: c.domain, path: c.path, expires: c.expires }));
|
|
272
|
+
} catch {}
|
|
273
|
+
data = { value: { entry, docs, solved: entry ? !entry.blocked : true, userAgent: ua, cookies } };
|
|
265
274
|
break;
|
|
266
275
|
}
|
|
267
276
|
default:
|
|
@@ -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
|
},
|
package/dist/core/sandbox.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { fastHtmlToMarkdown, setDomainCookie } from "./engine.js";
|
|
2
2
|
import { isAntiBotChallenge } from "./engine/fetcher.js";
|
|
3
|
+
import { getWarmSession, isDomainChallenged, markDomainChallenged } from "./engine/session.js";
|
|
3
4
|
/** Bot-check copy that survives HTML → markdown conversion (Cloudflare `/markdown`). */
|
|
4
5
|
const CHALLENGE_MARKDOWN = [
|
|
5
6
|
"just a moment",
|
|
@@ -79,74 +80,93 @@ export async function escalateToSandboxOrBrowser(url, options = {}) {
|
|
|
79
80
|
}
|
|
80
81
|
// Option B: Vercel Sandbox microVM execution (`@vercel/sandbox`)
|
|
81
82
|
if (vercelSandboxAvailable()) {
|
|
83
|
+
// Challenge memo: curl can never solve a JS challenge, so for domains known
|
|
84
|
+
// to serve one (or with a warm solved session) skip this tier entirely and
|
|
85
|
+
// save its ~4-6 s sandbox boot.
|
|
86
|
+
let host = "";
|
|
82
87
|
try {
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
88
|
+
host = new URL(url).hostname;
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
/* fall through to curl */
|
|
92
|
+
}
|
|
93
|
+
const skipCurlTier = host
|
|
94
|
+
? (await isDomainChallenged(host).catch(() => false)) || Boolean(await getWarmSession(host).catch(() => null))
|
|
95
|
+
: false;
|
|
96
|
+
if (!skipCurlTier) {
|
|
87
97
|
try {
|
|
88
|
-
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
cmd: "curl",
|
|
92
|
-
args: [
|
|
93
|
-
"-sSL",
|
|
94
|
-
"--compressed",
|
|
95
|
-
"--max-time",
|
|
96
|
-
"12",
|
|
97
|
-
"-D",
|
|
98
|
-
"/tmp/headers.txt",
|
|
99
|
-
...(proxyArg ? ["-x", process.env.PROXY_URL] : []),
|
|
100
|
-
"-H",
|
|
101
|
-
"User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36",
|
|
102
|
-
"-H",
|
|
103
|
-
"Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
|
104
|
-
"-H",
|
|
105
|
-
"Accept-Language: en-US,en;q=0.9",
|
|
106
|
-
url,
|
|
107
|
-
],
|
|
98
|
+
const { Sandbox } = await import("@vercel/sandbox");
|
|
99
|
+
const sandbox = await Sandbox.create({
|
|
100
|
+
timeout: options.timeoutMs ?? 20_000,
|
|
108
101
|
});
|
|
109
|
-
const html = await cmd.stdout();
|
|
110
102
|
try {
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
103
|
+
// Execute curl with browser cipher list + cookie capture inside the clean Linux microVM egress IP
|
|
104
|
+
const proxyArg = process.env.PROXY_URL ? `-x "${process.env.PROXY_URL}"` : "";
|
|
105
|
+
const cmd = await sandbox.runCommand({
|
|
106
|
+
cmd: "curl",
|
|
107
|
+
args: [
|
|
108
|
+
"-sSL",
|
|
109
|
+
"--compressed",
|
|
110
|
+
"--max-time",
|
|
111
|
+
"12",
|
|
112
|
+
"-D",
|
|
113
|
+
"/tmp/headers.txt",
|
|
114
|
+
...(proxyArg ? ["-x", process.env.PROXY_URL] : []),
|
|
115
|
+
"-H",
|
|
116
|
+
"User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36",
|
|
117
|
+
"-H",
|
|
118
|
+
"Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
|
119
|
+
"-H",
|
|
120
|
+
"Accept-Language: en-US,en;q=0.9",
|
|
121
|
+
url,
|
|
122
|
+
],
|
|
114
123
|
});
|
|
115
|
-
const
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
124
|
+
const html = await cmd.stdout();
|
|
125
|
+
try {
|
|
126
|
+
const headersCmd = await sandbox.runCommand({
|
|
127
|
+
cmd: "cat",
|
|
128
|
+
args: ["/tmp/headers.txt"],
|
|
129
|
+
});
|
|
130
|
+
const headersText = await headersCmd.stdout();
|
|
131
|
+
const setCookies = Array.from(headersText.matchAll(/^set-cookie:\s*([^;\r\n]+)/gim)).map((m) => m[1]);
|
|
132
|
+
if (setCookies.length > 0) {
|
|
133
|
+
setDomainCookie(new URL(url).hostname, setCookies.join("; "));
|
|
134
|
+
}
|
|
119
135
|
}
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
}
|
|
136
|
+
catch {
|
|
137
|
+
// ignore cookie extraction failure
|
|
138
|
+
}
|
|
139
|
+
// A challenge page is not content: never return it as a successful escalation.
|
|
140
|
+
// An empty shell (e.g. an AWS WAF 202 interstitial) is not content either:
|
|
141
|
+
// parse first and fall through to the browser tier instead.
|
|
142
|
+
if (html && html.length > 100 && !isAntiBotChallenge(html, 200)) {
|
|
143
|
+
const parsed = fastHtmlToMarkdown(html, url, {
|
|
144
|
+
retainImages: options.retainImages,
|
|
145
|
+
});
|
|
146
|
+
if (parsed.markdown.trim().length > 0) {
|
|
147
|
+
return {
|
|
148
|
+
html,
|
|
149
|
+
markdown: parsed.markdown,
|
|
150
|
+
title: parsed.title,
|
|
151
|
+
links: parsed.links,
|
|
152
|
+
statusCode: 200,
|
|
153
|
+
tier: "sandbox",
|
|
154
|
+
};
|
|
155
|
+
}
|
|
140
156
|
}
|
|
157
|
+
// The clean-VM curl could not produce content: this domain serves a JS
|
|
158
|
+
// challenge (or similar), so memo it and skip this tier next time.
|
|
159
|
+
if (host)
|
|
160
|
+
await markDomainChallenged(host).catch(() => { });
|
|
161
|
+
}
|
|
162
|
+
finally {
|
|
163
|
+
await sandbox.stop().catch(() => { });
|
|
141
164
|
}
|
|
142
165
|
}
|
|
143
|
-
|
|
144
|
-
|
|
166
|
+
catch {
|
|
167
|
+
// Sandbox unavailable or not configured in this environment
|
|
145
168
|
}
|
|
146
169
|
}
|
|
147
|
-
catch {
|
|
148
|
-
// Sandbox unavailable or not configured in this environment
|
|
149
|
-
}
|
|
150
170
|
}
|
|
151
171
|
// Option C: real browser that waits out the bot check (opt-in, slow).
|
|
152
172
|
if (options.browser) {
|
|
@@ -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.
|
|
3
|
+
"version": "1.3.0",
|
|
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.
|
|
130
|
+
"@webcrawl/client": "^1.3.0",
|
|
131
|
+
"@webcrawl/curl-impersonate": "^1.3.0",
|
|
131
132
|
"ai": "^7.0.150",
|
|
132
133
|
"impit": "^0.14.7",
|
|
133
134
|
"mammoth": "^1.13.0",
|