webcrawl 1.2.7 → 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.
@@ -1,5 +1,27 @@
1
- /** True when an HTML response (with optional status) is an AWS WAF challenge interstitial. */
2
- export declare function isAwsWafChallenge(html: string, status?: number): boolean;
1
+ /**
2
+ * AWS WAF challenge handling (local prototype).
3
+ *
4
+ * `webcrawl`'s bot-check detector (`isAntiBotChallenge`) only matches
5
+ * Cloudflare and Vercel Security Checkpoint signatures. AWS WAF serves
6
+ * its invisible JS challenge as **HTTP 202** with an empty `<title>` and
7
+ * none of those markers, so such pages come back as
8
+ * `Empty page (HTTP 202)` with `blocked: false` and never reach the
9
+ * stealth browser. This module adds the missing detection plus a local
10
+ * stealth-routing path so those pages can be solved and read anyway.
11
+ *
12
+ * Detection markers are layered (no single one is proof):
13
+ * - status 202 (documented Challenge interstitial status)
14
+ * - `awsWafCookieDomainList` / `AwsWafIntegration` (documented JS API)
15
+ * - `aws-waf-token` cookie reference
16
+ * - `<div id="challenge-container">` + `/__challenge_<id>/.../challenge.js`
17
+ *
18
+ * The detection itself now lives in the shared detector
19
+ * (`isAwsWafChallenge` in `./fetcher.js`, also folded into
20
+ * `isAntiBotChallenge`); this module re-exports it and adds the local
21
+ * stealth-routing path so challenge pages can be solved and read anyway.
22
+ */
23
+ import { isAwsWafChallenge } from "./fetcher.js";
24
+ export { isAwsWafChallenge };
3
25
  /** True when a fetch response is any known bot check (upstream detector + AWS WAF). */
4
26
  export declare function isBlockedLike(html: string, status?: number): boolean;
5
27
  export interface AwsWafPage {
@@ -14,40 +14,15 @@
14
14
  * - `awsWafCookieDomainList` / `AwsWafIntegration` (documented JS API)
15
15
  * - `aws-waf-token` cookie reference
16
16
  * - `<div id="challenge-container">` + `/__challenge_<id>/.../challenge.js`
17
+ *
18
+ * The detection itself now lives in the shared detector
19
+ * (`isAwsWafChallenge` in `./fetcher.js`, also folded into
20
+ * `isAntiBotChallenge`); this module re-exports it and adds the local
21
+ * stealth-routing path so challenge pages can be solved and read anyway.
17
22
  */
18
- import { fetchWithTlsImpersonation, fastHtmlToMarkdown, isAntiBotChallenge } from "../engine.js";
19
- /** Markers that only appear on an AWS WAF challenge interstitial. */
20
- const AWS_WAF_MARKERS = [
21
- "awswafcookiedomainlist",
22
- "awswafintegration",
23
- "aws-waf-token",
24
- "id=\"challenge-container\"",
25
- "/__challenge_",
26
- ];
27
- /** True when an HTML response (with optional status) is an AWS WAF challenge interstitial. */
28
- export function isAwsWafChallenge(html, status) {
29
- const lower = String(html ?? "").slice(0, 8000).toLowerCase();
30
- const hasMarker = AWS_WAF_MARKERS.some((m) => lower.includes(m));
31
- if (hasMarker)
32
- return true;
33
- // 202 alone is the documented Challenge status, but APIs can legitimately
34
- // return 202 - only treat it as a challenge when the body is empty/challenge-ish.
35
- if (status === 202) {
36
- // 202 is the documented Challenge interstitial status. The interstitial's
37
- // entire payload is <script>/<noscript>, so visible text is empty.
38
- if (!lower.includes("<html"))
39
- return false;
40
- const visibleText = lower
41
- .slice(0, 8000)
42
- .replace(/<script[\s\S]*?<\/script>/g, "")
43
- .replace(/<noscript[\s\S]*?<\/noscript>/g, "")
44
- .replace(/<style[\s\S]*?<\/style>/g, "")
45
- .replace(/<[^>]+>/g, " ")
46
- .trim();
47
- return visibleText.length === 0;
48
- }
49
- return false;
50
- }
23
+ import { fetchWithTlsImpersonation, isAntiBotChallenge, isAwsWafChallenge } from "./fetcher.js";
24
+ import { fastHtmlToMarkdown } from "./markdown.js";
25
+ export { isAwsWafChallenge };
51
26
  /** True when a fetch response is any known bot check (upstream detector + AWS WAF). */
52
27
  export function isBlockedLike(html, status) {
53
28
  return isAntiBotChallenge(html ?? "", status ?? 200) || isAwsWafChallenge(html ?? "", status);
@@ -23,6 +23,19 @@ 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;
32
+ /**
33
+ * True when an HTML response (with optional status) is an AWS WAF challenge
34
+ * interstitial: a marker match proves it regardless of status; otherwise a
35
+ * documented HTTP 202 whose payload is a `<script>`-only HTML shell with zero
36
+ * visible text (APIs legitimately return 202 too, so status alone is not proof).
37
+ */
38
+ export declare function isAwsWafChallenge(html: string, status?: number): boolean;
26
39
  export declare function isAntiBotChallenge(html: string, status: number): boolean;
27
40
  /**
28
41
  * Fetches a URL using `impit` (BoringSSL Chrome 136 TLS JA3/JA4 + HTTP/2 fingerprint impersonation)
@@ -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++) {
@@ -47,18 +58,55 @@ function hashCode(str) {
47
58
  }
48
59
  return hash;
49
60
  }
61
+ /** Markers that only appear on an AWS WAF challenge interstitial. */
62
+ const AWS_WAF_CHALLENGE_MARKERS = [
63
+ "awswafcookiedomainlist",
64
+ "awswafintegration",
65
+ "aws-waf-token",
66
+ 'id="challenge-container"',
67
+ "/__challenge_",
68
+ ];
69
+ /**
70
+ * True when an HTML response (with optional status) is an AWS WAF challenge
71
+ * interstitial: a marker match proves it regardless of status; otherwise a
72
+ * documented HTTP 202 whose payload is a `<script>`-only HTML shell with zero
73
+ * visible text (APIs legitimately return 202 too, so status alone is not proof).
74
+ */
75
+ export function isAwsWafChallenge(html, status) {
76
+ const lower = String(html ?? "").slice(0, 8000).toLowerCase();
77
+ if (AWS_WAF_CHALLENGE_MARKERS.some((m) => lower.includes(m)))
78
+ return true;
79
+ if (status === 202) {
80
+ if (!lower.includes("<html"))
81
+ return false;
82
+ const visibleText = lower
83
+ .slice(0, 8000)
84
+ .replace(/<script[\s\S]*?<\/script>/g, "")
85
+ .replace(/<noscript[\s\S]*?<\/noscript>/g, "")
86
+ .replace(/<style[\s\S]*?<\/style>/g, "")
87
+ .replace(/<[^>]+>/g, " ")
88
+ .trim();
89
+ return visibleText.length === 0;
90
+ }
91
+ return false;
92
+ }
50
93
  export function isAntiBotChallenge(html, status) {
51
94
  if (status === 403 || status === 429 || status === 503) {
52
95
  return true;
53
96
  }
54
97
  const lower = html.slice(0, 6000).toLowerCase();
55
- return (lower.includes("cf-browser-verification") ||
98
+ if (lower.includes("cf-browser-verification") ||
56
99
  lower.includes("challenge-platform") ||
57
100
  lower.includes("just a moment...") ||
58
101
  lower.includes("vercel security checkpoint") ||
59
102
  lower.includes("_vercel/security") ||
60
103
  lower.includes("enable javascript and cookies to continue") ||
61
- lower.includes("_cf_chl_opt"));
104
+ lower.includes("_cf_chl_opt")) {
105
+ return true;
106
+ }
107
+ // AWS WAF serves its invisible JS challenge as HTTP 202 with none of the
108
+ // Cloudflare/Vercel markers, so those pages used to pass as real content.
109
+ return isAwsWafChallenge(html, status);
62
110
  }
63
111
  let sharedImpit = null;
64
112
  /** One request, redirects NOT followed (callers validate each hop via followSafely). */
@@ -104,6 +152,31 @@ async function rawGet(url, reqHeaders, timeoutMs, maxBytes) {
104
152
  * Every redirect hop is checked against the SSRF guard.
105
153
  */
106
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
+ }
107
180
  const { res, finalUrl } = await followSafely(url, (hop) => rawGet(hop, getBrowserHeaders(hop), timeoutMs));
108
181
  if (res.headers["set-cookie"]) {
109
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
  }
@@ -155,7 +155,7 @@ try {
155
155
  const PER_DOC = 1500000;
156
156
  const TOTAL = 10000000;
157
157
  const started = Date.now();
158
- const markers = ["vercel security checkpoint", "just a moment...", "cf-browser-verification", "challenge-platform", "_cf_chl_opt", "enable javascript and cookies to continue", "_vercel/security", "attention required! | cloudflare"];
158
+ const markers = ["vercel security checkpoint", "just a moment...", "cf-browser-verification", "challenge-platform", "_cf_chl_opt", "enable javascript and cookies to continue", "_vercel/security", "attention required! | cloudflare", "awswafcookiedomainlist", "awswafintegration", "aws-waf-token", 'id="challenge-container"', "/__challenge_"];
159
159
  const looksBlocked = (s) => { const l = String(s || "").slice(0, 8000).toLowerCase(); return markers.some((m) => l.includes(m)); };
160
160
  let ua = "";
161
161
  try {
@@ -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,70 +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
  }
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
+ }
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(() => { });
120
161
  }
121
- catch {
122
- // ignore cookie extraction failure
123
- }
124
- // A challenge page is not content: never return it as a successful escalation.
125
- if (html && html.length > 100 && !isAntiBotChallenge(html, 200)) {
126
- const parsed = fastHtmlToMarkdown(html, url, {
127
- retainImages: options.retainImages,
128
- });
129
- return {
130
- html,
131
- markdown: parsed.markdown,
132
- title: parsed.title,
133
- links: parsed.links,
134
- statusCode: 200,
135
- tier: "sandbox",
136
- };
162
+ finally {
163
+ await sandbox.stop().catch(() => { });
137
164
  }
138
165
  }
139
- finally {
140
- await sandbox.stop().catch(() => { });
166
+ catch {
167
+ // Sandbox unavailable or not configured in this environment
141
168
  }
142
169
  }
143
- catch {
144
- // Sandbox unavailable or not configured in this environment
145
- }
146
170
  }
147
171
  // Option C: real browser that waits out the bot check (opt-in, slow).
148
172
  if (options.browser) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webcrawl",
3
- "version": "1.2.7",
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.7",
130
+ "@webcrawl/client": "^1.2.9",
131
131
  "ai": "^7.0.150",
132
132
  "impit": "^0.14.7",
133
133
  "mammoth": "^1.13.0",