webcrawl 1.2.8 → 1.2.9

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.
@@ -23,6 +23,12 @@ export interface ScrapeResult {
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
@@ -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++) {
@@ -141,6 +152,31 @@ async function rawGet(url, reqHeaders, timeoutMs, maxBytes) {
141
152
  * Every redirect hop is checked against the SSRF guard.
142
153
  */
143
154
  export async function fetchWithTlsImpersonation(url, timeoutMs = 12_000) {
155
+ // Session hydration: when a warm stealth session exists for this domain, its
156
+ // solved cookies (and the UA they were solved under — bound cookies require
157
+ // the pair) ride the request so the lightweight tier can skip the challenge.
158
+ let hostname = "";
159
+ try {
160
+ hostname = new URL(url).hostname;
161
+ }
162
+ catch {
163
+ /* invalid URL handled below */
164
+ }
165
+ if (hostname) {
166
+ try {
167
+ const { getWarmSession } = await import("./session.js");
168
+ const warm = await getWarmSession(hostname);
169
+ if (warm) {
170
+ if (warm.cookieHeader)
171
+ setDomainCookie(hostname, warm.cookieHeader);
172
+ if (warm.userAgent)
173
+ setDomainUserAgent(hostname, warm.userAgent);
174
+ }
175
+ }
176
+ catch {
177
+ /* session module unavailable — plain fetch */
178
+ }
179
+ }
144
180
  const { res, finalUrl } = await followSafely(url, (hop) => rawGet(hop, getBrowserHeaders(hop), timeoutMs));
145
181
  if (res.headers["set-cookie"]) {
146
182
  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:
@@ -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) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webcrawl",
3
- "version": "1.2.8",
3
+ "version": "1.2.9",
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,7 @@
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.2.9",
131
131
  "ai": "^7.0.150",
132
132
  "impit": "^0.14.7",
133
133
  "mammoth": "^1.13.0",