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.
@@ -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 ua = BROWSER_USER_AGENTS[Math.abs(hashCode(targetUrl)) % BROWSER_USER_AGENTS.length];
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
- return await read(res.status, headersToObject(res.headers), () => res.arrayBuffer());
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
- * Fetches a URL using `impit` (BoringSSL Chrome 136 TLS JA3/JA4 + HTTP/2 fingerprint impersonation)
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 {
@@ -1,4 +1,5 @@
1
1
  export * from "./cache.js";
2
2
  export * from "./markdown.js";
3
+ export * from "./session.js";
3
4
  export * from "./fetcher.js";
4
5
  export * from "./crawler.js";
@@ -1,4 +1,5 @@
1
1
  export * from "./cache.js";
2
2
  export * from "./markdown.js";
3
+ export * from "./session.js";
3
4
  export * from "./fetcher.js";
4
5
  export * from "./crawler.js";
@@ -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 tag = provider ? { provider } : {};
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({ timeoutMs: Math.min(5 * 60_000, budgetMs + 90_000), remote: provider });
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 cost = handle ? await stopBrowser(handle).catch(() => null) : null;
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
- /** Stops the browser. For a remote provider, returns what the session cost (null when unknown). */
48
- export declare function stopBrowser(handle: BrowserHandle): Promise<RemoteSessionCost | null>;
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
- return startSandboxBrowser({ timeoutMs: opts.timeoutMs ?? 5 * 60_000, userAgent: opts.userAgent });
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
- /** Stops the browser. For a remote provider, returns what the session cost (null when unknown). */
247
- export async function stopBrowser(handle) {
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
  }
@@ -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
- data = { value: { entry, docs, solved: entry ? !entry.blocked : true, userAgent: ua } };
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
  },
@@ -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
- const { Sandbox } = await import("@vercel/sandbox");
84
- const sandbox = await Sandbox.create({
85
- timeout: options.timeoutMs ?? 20_000,
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
- // Execute curl with browser cipher list + cookie capture inside the clean Linux microVM egress IP
89
- const proxyArg = process.env.PROXY_URL ? `-x "${process.env.PROXY_URL}"` : "";
90
- const cmd = await sandbox.runCommand({
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
- const headersCmd = await sandbox.runCommand({
112
- cmd: "cat",
113
- args: ["/tmp/headers.txt"],
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 headersText = await headersCmd.stdout();
116
- const setCookies = Array.from(headersText.matchAll(/^set-cookie:\s*([^;\r\n]+)/gim)).map((m) => m[1]);
117
- if (setCookies.length > 0) {
118
- setDomainCookie(new URL(url).hostname, setCookies.join("; "));
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
- catch {
122
- // ignore cookie extraction failure
123
- }
124
- // A challenge page is not content: never return it as a successful escalation.
125
- // An empty shell (e.g. an AWS WAF 202 interstitial) is not content either:
126
- // parse first and fall through to the browser tier instead.
127
- if (html && html.length > 100 && !isAntiBotChallenge(html, 200)) {
128
- const parsed = fastHtmlToMarkdown(html, url, {
129
- retainImages: options.retainImages,
130
- });
131
- if (parsed.markdown.trim().length > 0) {
132
- return {
133
- html,
134
- markdown: parsed.markdown,
135
- title: parsed.title,
136
- links: parsed.links,
137
- statusCode: 200,
138
- tier: "sandbox",
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
- finally {
144
- await sandbox.stop().catch(() => { });
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.2.8",
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.2.8",
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",