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.
- package/dist/core/engine/awswaf.d.ts +24 -2
- package/dist/core/engine/awswaf.js +8 -33
- package/dist/core/engine/fetcher.d.ts +13 -0
- package/dist/core/engine/fetcher.js +77 -4
- package/dist/core/engine/index.d.ts +1 -0
- package/dist/core/engine/index.js +1 -0
- package/dist/core/engine/session.d.ts +69 -0
- package/dist/core/engine/session.js +156 -0
- package/dist/core/engine/stealth.d.ts +2 -0
- package/dist/core/engine/stealth.js +55 -4
- package/dist/core/interact/driver.d.ts +15 -2
- package/dist/core/interact/driver.js +86 -3
- package/dist/core/interact/vm.js +11 -2
- package/dist/core/sandbox.js +77 -53
- package/package.json +2 -2
|
@@ -1,5 +1,27 @@
|
|
|
1
|
-
/**
|
|
2
|
-
|
|
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,
|
|
19
|
-
|
|
20
|
-
|
|
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
|
|
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
|
-
|
|
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 {
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stealth session reuse: warm-sandbox registry, cookie-replay state and the
|
|
3
|
+
* JS-challenge memo.
|
|
4
|
+
*
|
|
5
|
+
* A solved bot check (AWS WAF, Cloudflare, Vercel checkpoint) leaves behind a
|
|
6
|
+
* valuable session: the sandbox's egress IP, its User-Agent and the cleared
|
|
7
|
+
* cookies. Instead of throwing that away with the sandbox, this module keeps:
|
|
8
|
+
* - a warm registry (domain -> sandboxId + UA + cookies) so the next query
|
|
9
|
+
* reconnects to the still-running sandbox (`Sandbox.get`) instead of
|
|
10
|
+
* cold-booting and re-solving;
|
|
11
|
+
* - the cookie header for replay on the lightweight TLS-impersonated tier
|
|
12
|
+
* (works only where the provider does not bind tokens to the solver IP —
|
|
13
|
+
* Cloudflare's `cf_clearance` does; AWS WAF's 300 s immunity may not);
|
|
14
|
+
* - a memo of domains known to serve JS challenges, so the useless
|
|
15
|
+
* curl-sandbox tier is skipped for them.
|
|
16
|
+
*
|
|
17
|
+
* Everything is in-memory by default; `registerExternalSessionStore` plugs in
|
|
18
|
+
* persistence (Postgres in the hosted app) so warm sessions survive across
|
|
19
|
+
* serverless instances. A registered store is always optional: every call
|
|
20
|
+
* degrades gracefully when it is unavailable.
|
|
21
|
+
*/
|
|
22
|
+
/** A solved stealth session kept for reuse. */
|
|
23
|
+
export interface WarmSession {
|
|
24
|
+
domain: string;
|
|
25
|
+
sandboxId: string;
|
|
26
|
+
userAgent: string;
|
|
27
|
+
/** `Cookie:` header value for the solved session (UA-paired: replay both or neither). */
|
|
28
|
+
cookieHeader: string;
|
|
29
|
+
/** Epoch ms after which the entry is stale. */
|
|
30
|
+
expiresAt: number;
|
|
31
|
+
}
|
|
32
|
+
export interface ExternalSessionStore {
|
|
33
|
+
getWarmSession(domain: string): Promise<WarmSession | null>;
|
|
34
|
+
saveWarmSession(session: WarmSession): Promise<void>;
|
|
35
|
+
deleteWarmSession(domain: string): Promise<void>;
|
|
36
|
+
markDomainChallenged(domain: string): Promise<void>;
|
|
37
|
+
isDomainChallenged(domain: string): Promise<boolean>;
|
|
38
|
+
}
|
|
39
|
+
/** How long a solved session is offered for warm reconnect / cookie replay. */
|
|
40
|
+
export declare const WARM_TTL_MS: number;
|
|
41
|
+
/** VM `timeout` headroom set at Sandbox.create so a warm sandbox outlives its registry TTL. */
|
|
42
|
+
export declare const WARM_SANDBOX_TIMEOUT_MS: number;
|
|
43
|
+
/** Cap on concurrently warm sandboxes; LRU entries beyond the cap are evicted. */
|
|
44
|
+
export declare const WARM_CAP = 4;
|
|
45
|
+
/** How long a domain stays on the JS-challenge memo. */
|
|
46
|
+
export declare const CHALLENGE_MEMO_TTL_MS: number;
|
|
47
|
+
export declare function registerExternalSessionStore(store: ExternalSessionStore): void;
|
|
48
|
+
export interface CookieLike {
|
|
49
|
+
name: string;
|
|
50
|
+
value: string;
|
|
51
|
+
domain?: string;
|
|
52
|
+
path?: string;
|
|
53
|
+
expires?: number;
|
|
54
|
+
}
|
|
55
|
+
/** Serializes captured browser cookies into a `Cookie:` header value (drops expired). */
|
|
56
|
+
export declare function cookieHeaderFrom(cookies: CookieLike[]): string;
|
|
57
|
+
/** Best-available warm session for a domain (in-memory first, then the external store). */
|
|
58
|
+
export declare function getWarmSession(domain: string): Promise<WarmSession | null>;
|
|
59
|
+
/**
|
|
60
|
+
* Registers a solved session as warm. Returns the sandboxIds evicted by the
|
|
61
|
+
* cap — the caller must stop those sandboxes so idle compute does not linger.
|
|
62
|
+
*/
|
|
63
|
+
export declare function saveWarmSession(session: WarmSession): Promise<string[]>;
|
|
64
|
+
export declare function deleteWarmSession(domain: string): Promise<void>;
|
|
65
|
+
/** Marks a domain as known to serve JS challenges (skips the curl tier next time). */
|
|
66
|
+
export declare function markDomainChallenged(domain: string): Promise<void>;
|
|
67
|
+
export declare function isDomainChallenged(domain: string): Promise<boolean>;
|
|
68
|
+
/** Test helper: resets all in-memory state (external stores are untouched). */
|
|
69
|
+
export declare function resetSessionStateForTests(): void;
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stealth session reuse: warm-sandbox registry, cookie-replay state and the
|
|
3
|
+
* JS-challenge memo.
|
|
4
|
+
*
|
|
5
|
+
* A solved bot check (AWS WAF, Cloudflare, Vercel checkpoint) leaves behind a
|
|
6
|
+
* valuable session: the sandbox's egress IP, its User-Agent and the cleared
|
|
7
|
+
* cookies. Instead of throwing that away with the sandbox, this module keeps:
|
|
8
|
+
* - a warm registry (domain -> sandboxId + UA + cookies) so the next query
|
|
9
|
+
* reconnects to the still-running sandbox (`Sandbox.get`) instead of
|
|
10
|
+
* cold-booting and re-solving;
|
|
11
|
+
* - the cookie header for replay on the lightweight TLS-impersonated tier
|
|
12
|
+
* (works only where the provider does not bind tokens to the solver IP —
|
|
13
|
+
* Cloudflare's `cf_clearance` does; AWS WAF's 300 s immunity may not);
|
|
14
|
+
* - a memo of domains known to serve JS challenges, so the useless
|
|
15
|
+
* curl-sandbox tier is skipped for them.
|
|
16
|
+
*
|
|
17
|
+
* Everything is in-memory by default; `registerExternalSessionStore` plugs in
|
|
18
|
+
* persistence (Postgres in the hosted app) so warm sessions survive across
|
|
19
|
+
* serverless instances. A registered store is always optional: every call
|
|
20
|
+
* degrades gracefully when it is unavailable.
|
|
21
|
+
*/
|
|
22
|
+
/** How long a solved session is offered for warm reconnect / cookie replay. */
|
|
23
|
+
export const WARM_TTL_MS = 8 * 60_000;
|
|
24
|
+
/** VM `timeout` headroom set at Sandbox.create so a warm sandbox outlives its registry TTL. */
|
|
25
|
+
export const WARM_SANDBOX_TIMEOUT_MS = 10 * 60_000;
|
|
26
|
+
/** Cap on concurrently warm sandboxes; LRU entries beyond the cap are evicted. */
|
|
27
|
+
export const WARM_CAP = 4;
|
|
28
|
+
/** How long a domain stays on the JS-challenge memo. */
|
|
29
|
+
export const CHALLENGE_MEMO_TTL_MS = 30 * 60_000;
|
|
30
|
+
let external = null;
|
|
31
|
+
export function registerExternalSessionStore(store) {
|
|
32
|
+
external = store;
|
|
33
|
+
}
|
|
34
|
+
const warm = new Map();
|
|
35
|
+
const challenged = new Map();
|
|
36
|
+
function reap(now = Date.now()) {
|
|
37
|
+
for (const [d, s] of warm)
|
|
38
|
+
if (s.expiresAt <= now)
|
|
39
|
+
warm.delete(d);
|
|
40
|
+
for (const [d, t] of challenged)
|
|
41
|
+
if (t <= now)
|
|
42
|
+
challenged.delete(d);
|
|
43
|
+
}
|
|
44
|
+
/** Serializes captured browser cookies into a `Cookie:` header value (drops expired). */
|
|
45
|
+
export function cookieHeaderFrom(cookies) {
|
|
46
|
+
const now = Date.now() / 1000;
|
|
47
|
+
return cookies
|
|
48
|
+
.filter((c) => c.name && c.value && (!c.expires || c.expires > now))
|
|
49
|
+
.map((c) => `${c.name}=${c.value}`)
|
|
50
|
+
.join("; ");
|
|
51
|
+
}
|
|
52
|
+
/** Best-available warm session for a domain (in-memory first, then the external store). */
|
|
53
|
+
export async function getWarmSession(domain) {
|
|
54
|
+
if (!domain)
|
|
55
|
+
return null;
|
|
56
|
+
reap();
|
|
57
|
+
const local = warm.get(domain);
|
|
58
|
+
if (local && local.expiresAt > Date.now())
|
|
59
|
+
return local;
|
|
60
|
+
if (local)
|
|
61
|
+
warm.delete(domain);
|
|
62
|
+
if (external) {
|
|
63
|
+
try {
|
|
64
|
+
const stored = await external.getWarmSession(domain);
|
|
65
|
+
if (stored && stored.expiresAt > Date.now()) {
|
|
66
|
+
warm.set(domain, stored);
|
|
67
|
+
return stored;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
/* store unavailable */
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Registers a solved session as warm. Returns the sandboxIds evicted by the
|
|
78
|
+
* cap — the caller must stop those sandboxes so idle compute does not linger.
|
|
79
|
+
*/
|
|
80
|
+
export async function saveWarmSession(session) {
|
|
81
|
+
if (!session.domain)
|
|
82
|
+
return [];
|
|
83
|
+
reap();
|
|
84
|
+
warm.delete(session.domain);
|
|
85
|
+
warm.set(session.domain, { ...session });
|
|
86
|
+
const evicted = [];
|
|
87
|
+
while (warm.size > WARM_CAP) {
|
|
88
|
+
const [d, s] = warm.entries().next().value;
|
|
89
|
+
warm.delete(d);
|
|
90
|
+
if (s.sandboxId && s.sandboxId !== session.sandboxId)
|
|
91
|
+
evicted.push(s.sandboxId);
|
|
92
|
+
}
|
|
93
|
+
if (external) {
|
|
94
|
+
try {
|
|
95
|
+
await external.saveWarmSession(session);
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
/* store unavailable */
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return evicted;
|
|
102
|
+
}
|
|
103
|
+
export async function deleteWarmSession(domain) {
|
|
104
|
+
if (!domain)
|
|
105
|
+
return;
|
|
106
|
+
warm.delete(domain);
|
|
107
|
+
if (external) {
|
|
108
|
+
try {
|
|
109
|
+
await external.deleteWarmSession(domain);
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
/* store unavailable */
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
/** Marks a domain as known to serve JS challenges (skips the curl tier next time). */
|
|
117
|
+
export async function markDomainChallenged(domain) {
|
|
118
|
+
if (!domain)
|
|
119
|
+
return;
|
|
120
|
+
challenged.set(domain, Date.now() + CHALLENGE_MEMO_TTL_MS);
|
|
121
|
+
if (external) {
|
|
122
|
+
try {
|
|
123
|
+
await external.markDomainChallenged(domain);
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
/* store unavailable */
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
export async function isDomainChallenged(domain) {
|
|
131
|
+
if (!domain)
|
|
132
|
+
return false;
|
|
133
|
+
reap();
|
|
134
|
+
const until = challenged.get(domain);
|
|
135
|
+
if (until && until > Date.now())
|
|
136
|
+
return true;
|
|
137
|
+
if (until)
|
|
138
|
+
challenged.delete(domain);
|
|
139
|
+
if (external) {
|
|
140
|
+
try {
|
|
141
|
+
if (await external.isDomainChallenged(domain)) {
|
|
142
|
+
challenged.set(domain, Date.now() + CHALLENGE_MEMO_TTL_MS);
|
|
143
|
+
return true;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
/* store unavailable */
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
152
|
+
/** Test helper: resets all in-memory state (external stores are untouched). */
|
|
153
|
+
export function resetSessionStateForTests() {
|
|
154
|
+
warm.clear();
|
|
155
|
+
challenged.clear();
|
|
156
|
+
}
|
|
@@ -45,6 +45,8 @@ export declare function stealthFetch(entryUrl: string, items: StealthItem[], opt
|
|
|
45
45
|
followSitemaps?: number;
|
|
46
46
|
/** Allow the paid remote provider. Off for unbilled (anonymous) runs. Default true. */
|
|
47
47
|
remote?: boolean;
|
|
48
|
+
/** Keep the solved session warm (sandbox left running + cookies saved) for repeat queries. */
|
|
49
|
+
keepWarm?: boolean;
|
|
48
50
|
}): Promise<StealthResult>;
|
|
49
51
|
/** Extracts `<loc>` entries from a sitemap or sitemap index. */
|
|
50
52
|
export declare function parseSitemapLocs(xml: string): {
|
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
* Never fabricates content: a page still showing a check comes back `blocked`.
|
|
11
11
|
*/
|
|
12
12
|
import { isAntiBotChallenge } from "./fetcher.js";
|
|
13
|
+
import { setDomainCookie, setDomainUserAgent } from "./fetcher.js";
|
|
14
|
+
import { cookieHeaderFrom, getWarmSession, saveWarmSession, WARM_TTL_MS } from "./session.js";
|
|
13
15
|
import { remoteStealthProvider } from "../interact/remote.js";
|
|
14
16
|
/**
|
|
15
17
|
* True when a real browser can run here: our own (sandbox in prod, local
|
|
@@ -37,19 +39,29 @@ export async function stealthFetch(entryUrl, items, opts = {}) {
|
|
|
37
39
|
}
|
|
38
40
|
const { startBrowser, runBrowserStep, stopBrowser } = await import("../interact/driver.js");
|
|
39
41
|
const provider = opts.remote === false ? null : remoteStealthProvider();
|
|
40
|
-
const
|
|
42
|
+
const domain = hostOf(entryUrl);
|
|
43
|
+
// A warm session for this domain means the challenge is already solved: short
|
|
44
|
+
// solve budget. A cold solve needs the full budget.
|
|
45
|
+
const hadWarm = provider ? null : await getWarmSession(domain).catch(() => null);
|
|
46
|
+
const tag = provider ? { provider } : { warm: Boolean(hadWarm) };
|
|
41
47
|
let handle = null;
|
|
42
48
|
// Filled in by `finally` once the provider reports the session cost.
|
|
43
49
|
const out = {};
|
|
50
|
+
let capturedCookies = [];
|
|
51
|
+
let capturedUa = "";
|
|
44
52
|
try {
|
|
45
53
|
const budgetMs = opts.budgetMs ?? 90_000;
|
|
46
|
-
handle = await startBrowser({
|
|
54
|
+
handle = await startBrowser({
|
|
55
|
+
timeoutMs: Math.min(5 * 60_000, budgetMs + 90_000),
|
|
56
|
+
remote: provider,
|
|
57
|
+
domain: provider ? undefined : domain,
|
|
58
|
+
});
|
|
47
59
|
const res = await runBrowserStep(handle, {
|
|
48
60
|
type: "fetchMany",
|
|
49
61
|
entry: entryUrl,
|
|
50
62
|
items: items.slice(0, 40),
|
|
51
63
|
budgetMs,
|
|
52
|
-
solveMs: opts.solveMs ?? 20_000,
|
|
64
|
+
solveMs: hadWarm ? Math.min(opts.solveMs ?? 20_000, 8_000) : (opts.solveMs ?? 20_000),
|
|
53
65
|
followSitemaps: opts.followSitemaps ?? 0,
|
|
54
66
|
});
|
|
55
67
|
if (!res.ok) {
|
|
@@ -57,6 +69,13 @@ export async function stealthFetch(entryUrl, items, opts = {}) {
|
|
|
57
69
|
}
|
|
58
70
|
else {
|
|
59
71
|
const value = (res.data?.value ?? {});
|
|
72
|
+
capturedCookies = Array.isArray(value.cookies) ? value.cookies : [];
|
|
73
|
+
capturedUa = value.userAgent ?? "";
|
|
74
|
+
if (capturedUa)
|
|
75
|
+
setDomainUserAgent(domain, capturedUa);
|
|
76
|
+
if (capturedCookies.length > 0) {
|
|
77
|
+
setDomainCookie(domain, cookieHeaderFrom(capturedCookies));
|
|
78
|
+
}
|
|
60
79
|
out.result = {
|
|
61
80
|
solved: Boolean(value.solved),
|
|
62
81
|
entry: value.entry ?? null,
|
|
@@ -70,9 +89,32 @@ export async function stealthFetch(entryUrl, items, opts = {}) {
|
|
|
70
89
|
out.result = { solved: false, entry: null, docs: [], durationMs: 0, error: err instanceof Error ? err.message : String(err), ...tag };
|
|
71
90
|
}
|
|
72
91
|
finally {
|
|
73
|
-
const
|
|
92
|
+
const solved = out.result?.solved === true;
|
|
93
|
+
const keepWarm = !provider && solved && (opts.keepWarm ?? true) && Boolean(handle && handle.kind === "sandbox" && capturedUa);
|
|
94
|
+
const cost = handle
|
|
95
|
+
? await stopBrowser(handle, {
|
|
96
|
+
keepWarm,
|
|
97
|
+
domain,
|
|
98
|
+
userAgent: capturedUa,
|
|
99
|
+
cookieHeader: capturedCookies.length > 0 ? cookieHeaderFrom(capturedCookies) : "",
|
|
100
|
+
}).catch(() => null)
|
|
101
|
+
: null;
|
|
74
102
|
if (out.result && cost)
|
|
75
103
|
out.result.providerCostUsd = cost.costUsd;
|
|
104
|
+
if (!keepWarm && solved && capturedCookies.length > 0) {
|
|
105
|
+
// Even without a warm sandbox, remember the solved cookies for replay on
|
|
106
|
+
// the lightweight tier (UA-paired; rejection there just escalates).
|
|
107
|
+
await saveWarmSession({ domain, sandboxId: "", userAgent: capturedUa, cookieHeader: cookieHeaderFrom(capturedCookies), expiresAt: Date.now() + WARM_TTL_MS })
|
|
108
|
+
.catch(() => { });
|
|
109
|
+
}
|
|
110
|
+
console.info("[stealth] session result", {
|
|
111
|
+
domain,
|
|
112
|
+
solved,
|
|
113
|
+
warm: Boolean(hadWarm),
|
|
114
|
+
keepWarm,
|
|
115
|
+
cookieCount: capturedCookies.length,
|
|
116
|
+
durationMs: Date.now() - t0,
|
|
117
|
+
});
|
|
76
118
|
}
|
|
77
119
|
const result = out.result;
|
|
78
120
|
result.durationMs = Date.now() - t0;
|
|
@@ -88,6 +130,15 @@ export async function stealthFetch(entryUrl, items, opts = {}) {
|
|
|
88
130
|
}
|
|
89
131
|
return result;
|
|
90
132
|
}
|
|
133
|
+
/** Hostname of a URL, or "" when unparseable. */
|
|
134
|
+
function hostOf(url) {
|
|
135
|
+
try {
|
|
136
|
+
return new URL(url).hostname;
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
return "";
|
|
140
|
+
}
|
|
141
|
+
}
|
|
91
142
|
/** Extracts `<loc>` entries from a sitemap or sitemap index. */
|
|
92
143
|
export function parseSitemapLocs(xml) {
|
|
93
144
|
const pages = [];
|
|
@@ -42,8 +42,21 @@ export declare function startBrowser(opts?: {
|
|
|
42
42
|
timeoutMs?: number;
|
|
43
43
|
userAgent?: string;
|
|
44
44
|
remote?: RemoteProvider | null;
|
|
45
|
+
domain?: string;
|
|
45
46
|
}): Promise<BrowserHandle>;
|
|
46
47
|
export declare function runBrowserStep(handle: BrowserHandle, action: StepAction): Promise<StepResultLine>;
|
|
47
|
-
/**
|
|
48
|
-
|
|
48
|
+
/**
|
|
49
|
+
* Stops the browser. For a remote provider, returns what the session cost (null
|
|
50
|
+
* when unknown). With `keepWarm`, a sandbox browser is left running and
|
|
51
|
+
* registered (domain + sandboxId + UA) so the next call reconnects to it via
|
|
52
|
+
* `Sandbox.get` instead of cold-booting — the caller must pass `domain`.
|
|
53
|
+
* Evicted warm entries (over the cap) are stopped here so idle compute
|
|
54
|
+
* does not linger.
|
|
55
|
+
*/
|
|
56
|
+
export declare function stopBrowser(handle: BrowserHandle, opts?: {
|
|
57
|
+
keepWarm?: boolean;
|
|
58
|
+
domain?: string;
|
|
59
|
+
userAgent?: string;
|
|
60
|
+
cookieHeader?: string;
|
|
61
|
+
}): Promise<RemoteSessionCost | null>;
|
|
49
62
|
export { CDP_PORT };
|
|
@@ -15,6 +15,7 @@ import path from "node:path";
|
|
|
15
15
|
import { Sandbox } from "@vercel/sandbox";
|
|
16
16
|
import { CDP_PORT, SETUP_SCRIPT, STEP_SCRIPT, STEP_SCRIPT_PATH, VM_ROOT, WAIT_FOR_CDP_SCRIPT, chromeArgs, parseStepOutput, } from "./vm.js";
|
|
17
17
|
import { startRemoteSession, stopRemoteSession } from "./remote.js";
|
|
18
|
+
import { WARM_SANDBOX_TIMEOUT_MS, WARM_TTL_MS } from "../engine/session.js";
|
|
18
19
|
const snapshotId = () => process.env.WEBCRAWL_INTERACT_SNAPSHOT_ID || process.env.INTERACT_SNAPSHOT_ID || "";
|
|
19
20
|
const localMode = () => process.env.INTERACT_LOCAL_PLAYWRIGHT === "true";
|
|
20
21
|
/**
|
|
@@ -91,6 +92,44 @@ async function startSandboxBrowser(opts) {
|
|
|
91
92
|
throw err;
|
|
92
93
|
}
|
|
93
94
|
}
|
|
95
|
+
/** True when the sandbox's Chromium still answers on CDP (warm reconnect check). */
|
|
96
|
+
async function sandboxChromeAlive(sandbox) {
|
|
97
|
+
const ping = await sh(sandbox, `curl -sf -m 3 http://127.0.0.1:${CDP_PORT}/json/version >/dev/null && echo alive`, false);
|
|
98
|
+
return ping.exitCode === 0 && ping.stdout.includes("alive");
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Warm reconnect: fetch the sandbox this domain solved its challenge in, verify
|
|
102
|
+
* Chromium still lives, and reuse it (same IP, same UA, same cleared cookies).
|
|
103
|
+
* Returns null when no warm entry exists or the sandbox is gone (cold boot).
|
|
104
|
+
*/
|
|
105
|
+
async function startWarmSandboxBrowser(domain) {
|
|
106
|
+
if (!domain)
|
|
107
|
+
return null;
|
|
108
|
+
const { getWarmSession } = await import("../engine/session.js");
|
|
109
|
+
let warm;
|
|
110
|
+
try {
|
|
111
|
+
warm = await getWarmSession(domain);
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
if (!warm || !warm.sandboxId)
|
|
117
|
+
return null;
|
|
118
|
+
try {
|
|
119
|
+
const sandbox = await Sandbox.get({ sandboxId: warm.sandboxId });
|
|
120
|
+
if (!(await sandboxChromeAlive(sandbox))) {
|
|
121
|
+
await sandbox.stop().catch(() => { });
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
// Refresh the step runner in case this instance ships newer code.
|
|
125
|
+
await sandbox.writeFiles([{ path: STEP_SCRIPT_PATH, content: Buffer.from(STEP_SCRIPT) }]).catch(() => { });
|
|
126
|
+
console.info("[stealth] warm reconnect", { domain, sandboxId: warm.sandboxId });
|
|
127
|
+
return { kind: "sandbox", sandboxId: warm.sandboxId };
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
94
133
|
/* ------------------------------ local backend ----------------------------- */
|
|
95
134
|
async function resolveLocalPlaywright() {
|
|
96
135
|
const candidates = [
|
|
@@ -213,7 +252,18 @@ export async function startBrowser(opts = {}) {
|
|
|
213
252
|
return startLocalBrowser(opts);
|
|
214
253
|
if (!interactBrowserAvailable())
|
|
215
254
|
throw new Error("browser_unavailable");
|
|
216
|
-
|
|
255
|
+
// Warm reconnect first: a sandbox that already solved this domain's bot check
|
|
256
|
+
// keeps its IP / UA / cleared cookies — reusing it skips boot + re-solve.
|
|
257
|
+
if (opts.domain) {
|
|
258
|
+
const warm = await startWarmSandboxBrowser(opts.domain).catch(() => null);
|
|
259
|
+
if (warm)
|
|
260
|
+
return warm;
|
|
261
|
+
}
|
|
262
|
+
return startSandboxBrowser({
|
|
263
|
+
// Long VM timeout headroom so the sandbox can stay warm after this call.
|
|
264
|
+
timeoutMs: Math.max(opts.timeoutMs ?? 5 * 60_000, WARM_SANDBOX_TIMEOUT_MS),
|
|
265
|
+
userAgent: opts.userAgent,
|
|
266
|
+
});
|
|
217
267
|
}
|
|
218
268
|
function runLocalStep(scriptPath, playwright, cdp, action, extraEnv = {}) {
|
|
219
269
|
return new Promise((resolve) => {
|
|
@@ -243,8 +293,15 @@ export async function runBrowserStep(handle, action) {
|
|
|
243
293
|
}
|
|
244
294
|
return runSandboxStep(handle.sandboxId, action);
|
|
245
295
|
}
|
|
246
|
-
/**
|
|
247
|
-
|
|
296
|
+
/**
|
|
297
|
+
* Stops the browser. For a remote provider, returns what the session cost (null
|
|
298
|
+
* when unknown). With `keepWarm`, a sandbox browser is left running and
|
|
299
|
+
* registered (domain + sandboxId + UA) so the next call reconnects to it via
|
|
300
|
+
* `Sandbox.get` instead of cold-booting — the caller must pass `domain`.
|
|
301
|
+
* Evicted warm entries (over the cap) are stopped here so idle compute
|
|
302
|
+
* does not linger.
|
|
303
|
+
*/
|
|
304
|
+
export async function stopBrowser(handle, opts = {}) {
|
|
248
305
|
if (handle.kind === "local") {
|
|
249
306
|
try {
|
|
250
307
|
process.kill(handle.pid);
|
|
@@ -258,6 +315,32 @@ export async function stopBrowser(handle) {
|
|
|
258
315
|
const [cost] = await Promise.all([stopRemoteSession(handle), stopRunner(handle.runner)]);
|
|
259
316
|
return cost;
|
|
260
317
|
}
|
|
318
|
+
if (opts.keepWarm && opts.domain) {
|
|
319
|
+
const { saveWarmSession } = await import("../engine/session.js");
|
|
320
|
+
try {
|
|
321
|
+
const evicted = await saveWarmSession({
|
|
322
|
+
domain: opts.domain,
|
|
323
|
+
sandboxId: handle.sandboxId,
|
|
324
|
+
userAgent: opts.userAgent ?? "",
|
|
325
|
+
cookieHeader: opts.cookieHeader ?? "",
|
|
326
|
+
expiresAt: Date.now() + WARM_TTL_MS,
|
|
327
|
+
});
|
|
328
|
+
await Promise.all(evicted.map(async (id) => {
|
|
329
|
+
try {
|
|
330
|
+
const sandbox = await Sandbox.get({ sandboxId: id });
|
|
331
|
+
await sandbox.stop();
|
|
332
|
+
}
|
|
333
|
+
catch {
|
|
334
|
+
/* already stopped */
|
|
335
|
+
}
|
|
336
|
+
}));
|
|
337
|
+
console.info("[stealth] kept warm", { domain: opts.domain, sandboxId: handle.sandboxId, evicted: evicted.length });
|
|
338
|
+
return null;
|
|
339
|
+
}
|
|
340
|
+
catch {
|
|
341
|
+
/* registry unavailable — fall through to full stop */
|
|
342
|
+
}
|
|
343
|
+
}
|
|
261
344
|
await stopRunner(handle);
|
|
262
345
|
return null;
|
|
263
346
|
}
|
package/dist/core/interact/vm.js
CHANGED
|
@@ -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
|
-
|
|
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:
|
package/dist/core/sandbox.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { fastHtmlToMarkdown, setDomainCookie } from "./engine.js";
|
|
2
2
|
import { isAntiBotChallenge } from "./engine/fetcher.js";
|
|
3
|
+
import { getWarmSession, isDomainChallenged, markDomainChallenged } from "./engine/session.js";
|
|
3
4
|
/** Bot-check copy that survives HTML → markdown conversion (Cloudflare `/markdown`). */
|
|
4
5
|
const CHALLENGE_MARKDOWN = [
|
|
5
6
|
"just a moment",
|
|
@@ -79,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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
88
|
+
host = new URL(url).hostname;
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
/* fall through to curl */
|
|
92
|
+
}
|
|
93
|
+
const skipCurlTier = host
|
|
94
|
+
? (await isDomainChallenged(host).catch(() => false)) || Boolean(await getWarmSession(host).catch(() => null))
|
|
95
|
+
: false;
|
|
96
|
+
if (!skipCurlTier) {
|
|
87
97
|
try {
|
|
88
|
-
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
cmd: "curl",
|
|
92
|
-
args: [
|
|
93
|
-
"-sSL",
|
|
94
|
-
"--compressed",
|
|
95
|
-
"--max-time",
|
|
96
|
-
"12",
|
|
97
|
-
"-D",
|
|
98
|
-
"/tmp/headers.txt",
|
|
99
|
-
...(proxyArg ? ["-x", process.env.PROXY_URL] : []),
|
|
100
|
-
"-H",
|
|
101
|
-
"User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36",
|
|
102
|
-
"-H",
|
|
103
|
-
"Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
|
104
|
-
"-H",
|
|
105
|
-
"Accept-Language: en-US,en;q=0.9",
|
|
106
|
-
url,
|
|
107
|
-
],
|
|
98
|
+
const { Sandbox } = await import("@vercel/sandbox");
|
|
99
|
+
const sandbox = await Sandbox.create({
|
|
100
|
+
timeout: options.timeoutMs ?? 20_000,
|
|
108
101
|
});
|
|
109
|
-
const html = await cmd.stdout();
|
|
110
102
|
try {
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
103
|
+
// Execute curl with browser cipher list + cookie capture inside the clean Linux microVM egress IP
|
|
104
|
+
const proxyArg = process.env.PROXY_URL ? `-x "${process.env.PROXY_URL}"` : "";
|
|
105
|
+
const cmd = await sandbox.runCommand({
|
|
106
|
+
cmd: "curl",
|
|
107
|
+
args: [
|
|
108
|
+
"-sSL",
|
|
109
|
+
"--compressed",
|
|
110
|
+
"--max-time",
|
|
111
|
+
"12",
|
|
112
|
+
"-D",
|
|
113
|
+
"/tmp/headers.txt",
|
|
114
|
+
...(proxyArg ? ["-x", process.env.PROXY_URL] : []),
|
|
115
|
+
"-H",
|
|
116
|
+
"User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36",
|
|
117
|
+
"-H",
|
|
118
|
+
"Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
|
119
|
+
"-H",
|
|
120
|
+
"Accept-Language: en-US,en;q=0.9",
|
|
121
|
+
url,
|
|
122
|
+
],
|
|
114
123
|
});
|
|
115
|
-
const
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
124
|
+
const html = await cmd.stdout();
|
|
125
|
+
try {
|
|
126
|
+
const headersCmd = await sandbox.runCommand({
|
|
127
|
+
cmd: "cat",
|
|
128
|
+
args: ["/tmp/headers.txt"],
|
|
129
|
+
});
|
|
130
|
+
const headersText = await headersCmd.stdout();
|
|
131
|
+
const setCookies = Array.from(headersText.matchAll(/^set-cookie:\s*([^;\r\n]+)/gim)).map((m) => m[1]);
|
|
132
|
+
if (setCookies.length > 0) {
|
|
133
|
+
setDomainCookie(new URL(url).hostname, setCookies.join("; "));
|
|
134
|
+
}
|
|
119
135
|
}
|
|
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
|
-
|
|
122
|
-
|
|
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
|
-
|
|
140
|
-
|
|
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.
|
|
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.
|
|
130
|
+
"@webcrawl/client": "^1.2.9",
|
|
131
131
|
"ai": "^7.0.150",
|
|
132
132
|
"impit": "^0.14.7",
|
|
133
133
|
"mammoth": "^1.13.0",
|