@webcrawl/mcp 1.4.2 → 1.4.3
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/README.md +1 -1
- package/dist/bin.js +5 -7
- package/dist/core/engine/awswaf.d.ts +59 -0
- package/dist/core/engine/awswaf.js +164 -0
- package/dist/core/engine/cache.d.ts +70 -0
- package/dist/core/engine/cache.js +297 -0
- package/dist/core/engine/chrome.d.ts +14 -0
- package/dist/core/engine/chrome.js +140 -0
- package/dist/core/engine/crawl-policy.d.ts +36 -0
- package/dist/core/engine/crawl-policy.js +134 -0
- package/dist/core/engine/crawler.d.ts +152 -0
- package/dist/core/engine/crawler.js +918 -0
- package/dist/core/engine/fetcher.d.ts +92 -0
- package/dist/core/engine/fetcher.js +494 -0
- package/dist/core/engine/index.d.ts +7 -0
- package/dist/core/engine/index.js +7 -0
- package/dist/core/engine/markdown.d.ts +33 -0
- package/dist/core/engine/markdown.js +334 -0
- package/dist/core/engine/path-scope.d.ts +21 -0
- package/dist/core/engine/path-scope.js +88 -0
- package/dist/core/engine/render.d.ts +41 -0
- package/dist/core/engine/render.js +138 -0
- package/dist/core/engine/safe-url.d.ts +29 -0
- package/dist/core/engine/safe-url.js +109 -0
- package/dist/core/engine/session.d.ts +93 -0
- package/dist/core/engine/session.js +250 -0
- package/dist/core/engine/stealth.d.ts +83 -0
- package/dist/core/engine/stealth.js +222 -0
- package/dist/core/engine.d.ts +1 -0
- package/dist/core/engine.js +1 -0
- package/dist/core/extract/business.d.ts +28 -0
- package/dist/core/extract/business.js +344 -0
- package/dist/core/extract/currency.d.ts +15 -0
- package/dist/core/extract/currency.js +259 -0
- package/dist/core/extract/cve-cache.d.ts +62 -0
- package/dist/core/extract/cve-cache.js +277 -0
- package/dist/core/extract/cve.d.ts +57 -0
- package/dist/core/extract/cve.js +397 -0
- package/dist/core/extract/dedupe-key.d.ts +12 -0
- package/dist/core/extract/dedupe-key.js +36 -0
- package/dist/core/extract/flights.d.ts +37 -0
- package/dist/core/extract/flights.js +373 -0
- package/dist/core/extract/hotels.d.ts +35 -0
- package/dist/core/extract/hotels.js +473 -0
- package/dist/core/extract/index.d.ts +9 -0
- package/dist/core/extract/index.js +9 -0
- package/dist/core/extract/real-estate.d.ts +53 -0
- package/dist/core/extract/real-estate.js +835 -0
- package/dist/core/extract/scaledown.d.ts +14 -0
- package/dist/core/extract/scaledown.js +484 -0
- package/dist/core/extract/shopping.d.ts +44 -0
- package/dist/core/extract/shopping.js +393 -0
- package/dist/core/extract/structured.d.ts +14 -0
- package/dist/core/extract/structured.js +144 -0
- package/dist/core/image-url.d.ts +5 -0
- package/dist/core/image-url.js +13 -0
- package/dist/core/interact/driver.d.ts +72 -0
- package/dist/core/interact/driver.js +384 -0
- package/dist/core/interact/remote.d.ts +41 -0
- package/dist/core/interact/remote.js +133 -0
- package/dist/core/interact/spans.d.ts +46 -0
- package/dist/core/interact/spans.js +32 -0
- package/dist/core/interact/validate.d.ts +34 -0
- package/dist/core/interact/validate.js +135 -0
- package/dist/core/interact/vm.d.ts +43 -0
- package/dist/core/interact/vm.js +338 -0
- package/dist/core/local-engine.d.ts +56 -0
- package/dist/core/local-engine.js +667 -0
- package/dist/core/parse/detect.d.ts +29 -0
- package/dist/core/parse/detect.js +152 -0
- package/dist/core/parse/fetch.d.ts +20 -0
- package/dist/core/parse/fetch.js +39 -0
- package/dist/core/parse/image.d.ts +13 -0
- package/dist/core/parse/image.js +100 -0
- package/dist/core/parse/index.d.ts +53 -0
- package/dist/core/parse/index.js +153 -0
- package/dist/core/parse/limits.d.ts +12 -0
- package/dist/core/parse/limits.js +29 -0
- package/dist/core/parse/media.d.ts +42 -0
- package/dist/core/parse/media.js +238 -0
- package/dist/core/parse/merge.d.ts +19 -0
- package/dist/core/parse/merge.js +48 -0
- package/dist/core/parse/ocr.d.ts +25 -0
- package/dist/core/parse/ocr.js +121 -0
- package/dist/core/parse/office.d.ts +28 -0
- package/dist/core/parse/office.js +83 -0
- package/dist/core/parse/pdf.d.ts +27 -0
- package/dist/core/parse/pdf.js +156 -0
- package/dist/core/parse/types.d.ts +38 -0
- package/dist/core/parse/types.js +1 -0
- package/dist/core/parse/youtube.d.ts +30 -0
- package/dist/core/parse/youtube.js +264 -0
- package/dist/core/ranker/decision.d.ts +28 -0
- package/dist/core/ranker/decision.js +151 -0
- package/dist/core/ranker/index.d.ts +5 -0
- package/dist/core/ranker/index.js +361 -0
- package/dist/core/ranker/lexical.d.ts +82 -0
- package/dist/core/ranker/lexical.js +643 -0
- package/dist/core/ranker/types.d.ts +90 -0
- package/dist/core/ranker/types.js +1 -0
- package/dist/core/ranker.d.ts +1 -0
- package/dist/core/ranker.js +1 -0
- package/dist/core/sandbox.d.ts +29 -0
- package/dist/core/sandbox.js +222 -0
- package/dist/core/search/catalog.d.ts +176 -0
- package/dist/core/search/catalog.js +488 -0
- package/dist/core/search/index.d.ts +3 -0
- package/dist/core/search/index.js +3 -0
- package/dist/core/search/pipeline.d.ts +7 -0
- package/dist/core/search/pipeline.js +719 -0
- package/dist/core/search/providers.d.ts +72 -0
- package/dist/core/search/providers.js +1270 -0
- package/dist/core/search.d.ts +1 -0
- package/dist/core/search.js +1 -0
- package/dist/core/targets/developer.d.ts +44 -0
- package/dist/core/targets/developer.js +338 -0
- package/dist/core/targets/index.d.ts +3 -0
- package/dist/core/targets/index.js +3 -0
- package/dist/core/targets/registry.d.ts +32 -0
- package/dist/core/targets/registry.js +411 -0
- package/dist/core/targets/types.d.ts +72 -0
- package/dist/core/targets/types.js +8 -0
- package/dist/core/targets.d.ts +1 -0
- package/dist/core/targets.js +1 -0
- package/dist/format.js +5 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/load-local-engine.d.ts +7 -0
- package/dist/load-local-engine.js +22 -0
- package/dist/server.js +3 -0
- package/package.json +11 -3
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { isIP } from "node:net";
|
|
2
|
+
import { lookup } from "node:dns/promises";
|
|
3
|
+
export class UnsafeUrlError extends Error {
|
|
4
|
+
constructor(message = "That address isn't publicly reachable") {
|
|
5
|
+
super(message);
|
|
6
|
+
this.name = "UnsafeUrlError";
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
export function isPrivateAddress(ip) {
|
|
10
|
+
const v = isIP(ip);
|
|
11
|
+
if (v === 4) {
|
|
12
|
+
const [a, b] = ip.split(".").map(Number);
|
|
13
|
+
return (a === 0 ||
|
|
14
|
+
a === 10 ||
|
|
15
|
+
a === 127 ||
|
|
16
|
+
(a === 100 && b >= 64 && b <= 127) ||
|
|
17
|
+
(a === 169 && b === 254) ||
|
|
18
|
+
(a === 172 && b >= 16 && b <= 31) ||
|
|
19
|
+
(a === 192 && b === 168) ||
|
|
20
|
+
(a === 198 && (b === 18 || b === 19)) ||
|
|
21
|
+
a >= 224);
|
|
22
|
+
}
|
|
23
|
+
if (v === 6) {
|
|
24
|
+
const s = ip.toLowerCase();
|
|
25
|
+
if (s.startsWith("::ffff:"))
|
|
26
|
+
return isPrivateAddress(s.slice(7));
|
|
27
|
+
return (s === "::" ||
|
|
28
|
+
s === "::1" ||
|
|
29
|
+
s.startsWith("fc") ||
|
|
30
|
+
s.startsWith("fd") ||
|
|
31
|
+
s.startsWith("fe8") ||
|
|
32
|
+
s.startsWith("fe9") ||
|
|
33
|
+
s.startsWith("fea") ||
|
|
34
|
+
s.startsWith("feb"));
|
|
35
|
+
}
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* True for 198.18.0.0/15, the benchmarking range that fake-IP proxies
|
|
40
|
+
* (Clash, Surge, sing-box, some VPN clients) hand out for every hostname.
|
|
41
|
+
*/
|
|
42
|
+
export function isFakeIpAddress(ip) {
|
|
43
|
+
const s = ip.toLowerCase().replace(/^::ffff:/, "");
|
|
44
|
+
if (isIP(s) !== 4)
|
|
45
|
+
return false;
|
|
46
|
+
const [a, b] = s.split(".").map(Number);
|
|
47
|
+
return a === 198 && (b === 18 || b === 19);
|
|
48
|
+
}
|
|
49
|
+
export const FAKE_IP_HINT = "That address isn't publicly reachable: it resolved to 198.18.x.x, which fake-IP proxies " +
|
|
50
|
+
"(Clash, Surge, sing-box, some VPNs) return for every site. Switch the proxy to redir-host/real-IP " +
|
|
51
|
+
"DNS mode, or set WEBCRAWL_ALLOW_PRIVATE_URLS=true for local runs you trust.";
|
|
52
|
+
const defaultResolve = async (h) => (await lookup(h, { all: true })).map((r) => r.address);
|
|
53
|
+
/** Local development escape hatch (never set in production). */
|
|
54
|
+
function allowPrivate() {
|
|
55
|
+
return process.env.WEBCRAWL_ALLOW_PRIVATE_URLS === "true";
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Throws `UnsafeUrlError` unless `raw` is an http(s) URL whose host resolves
|
|
59
|
+
* only to public addresses. Call it before every request and every redirect hop.
|
|
60
|
+
*/
|
|
61
|
+
export async function assertSafeUrl(raw, resolve = defaultResolve) {
|
|
62
|
+
let u;
|
|
63
|
+
try {
|
|
64
|
+
u = new URL(raw);
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
throw new UnsafeUrlError("Invalid URL");
|
|
68
|
+
}
|
|
69
|
+
if (u.protocol !== "http:" && u.protocol !== "https:") {
|
|
70
|
+
throw new UnsafeUrlError("Only http and https addresses are supported");
|
|
71
|
+
}
|
|
72
|
+
if (allowPrivate())
|
|
73
|
+
return u;
|
|
74
|
+
const host = u.hostname.replace(/^\[|\]$/g, "").toLowerCase();
|
|
75
|
+
if (host === "localhost" ||
|
|
76
|
+
host.endsWith(".localhost") ||
|
|
77
|
+
host.endsWith(".local") ||
|
|
78
|
+
host.endsWith(".internal")) {
|
|
79
|
+
throw new UnsafeUrlError();
|
|
80
|
+
}
|
|
81
|
+
const addrs = isIP(host) ? [host] : await resolve(host).catch(() => []);
|
|
82
|
+
if (addrs.length === 0)
|
|
83
|
+
throw new UnsafeUrlError("We couldn't find that website");
|
|
84
|
+
if (addrs.some(isPrivateAddress)) {
|
|
85
|
+
// Every address in the fake-IP range means a local proxy rewrote DNS,
|
|
86
|
+
// not that the site is internal: say so and name the workaround.
|
|
87
|
+
throw new UnsafeUrlError(addrs.every(isFakeIpAddress) ? FAKE_IP_HINT : undefined);
|
|
88
|
+
}
|
|
89
|
+
return u;
|
|
90
|
+
}
|
|
91
|
+
export const MAX_REDIRECTS = 8;
|
|
92
|
+
/**
|
|
93
|
+
* Runs `send` for `url` and follows redirects manually, validating every hop
|
|
94
|
+
* with `assertSafeUrl`. `send` must NOT follow redirects itself.
|
|
95
|
+
*/
|
|
96
|
+
export async function followSafely(url, send, resolve) {
|
|
97
|
+
let current = url;
|
|
98
|
+
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
|
|
99
|
+
await assertSafeUrl(current, resolve);
|
|
100
|
+
const res = await send(current);
|
|
101
|
+
const loc = res.headers["location"];
|
|
102
|
+
if (res.status >= 300 && res.status < 400 && loc) {
|
|
103
|
+
current = new URL(loc, current).toString();
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
return { res, finalUrl: current };
|
|
107
|
+
}
|
|
108
|
+
throw new UnsafeUrlError("Too many redirects");
|
|
109
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
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
|
+
/** Optional persistence for the residential-proxy memo (Tier P1). */
|
|
39
|
+
markDomainProxyNeeded?(domain: string): Promise<void>;
|
|
40
|
+
isDomainProxyNeeded?(domain: string): Promise<boolean>;
|
|
41
|
+
/** Optional persistence for the render-provider memo (Tier R). */
|
|
42
|
+
markDomainRenderNeeded?(domain: string): Promise<void>;
|
|
43
|
+
isDomainRenderNeeded?(domain: string): Promise<boolean>;
|
|
44
|
+
}
|
|
45
|
+
/** How long a solved session is offered for warm reconnect / cookie replay. */
|
|
46
|
+
export declare const WARM_TTL_MS: number;
|
|
47
|
+
/** VM `timeout` headroom set at Sandbox.create so a warm sandbox outlives its registry TTL. */
|
|
48
|
+
export declare const WARM_SANDBOX_TIMEOUT_MS: number;
|
|
49
|
+
/** Cap on concurrently warm sandboxes; LRU entries beyond the cap are evicted. */
|
|
50
|
+
export declare const WARM_CAP = 4;
|
|
51
|
+
/** How long a domain stays on the JS-challenge memo. */
|
|
52
|
+
export declare const CHALLENGE_MEMO_TTL_MS: number;
|
|
53
|
+
/** How long a domain stays on the residential-proxy memo (Tier P1). */
|
|
54
|
+
export declare const PROXY_MEMO_TTL_MS: number;
|
|
55
|
+
/** How long a domain stays on the render-provider memo (Tier R). */
|
|
56
|
+
export declare const RENDER_MEMO_TTL_MS: number;
|
|
57
|
+
export declare function registerExternalSessionStore(store: ExternalSessionStore): void;
|
|
58
|
+
export interface CookieLike {
|
|
59
|
+
name: string;
|
|
60
|
+
value: string;
|
|
61
|
+
domain?: string;
|
|
62
|
+
path?: string;
|
|
63
|
+
expires?: number;
|
|
64
|
+
}
|
|
65
|
+
/** Serializes captured browser cookies into a `Cookie:` header value (drops expired). */
|
|
66
|
+
export declare function cookieHeaderFrom(cookies: CookieLike[]): string;
|
|
67
|
+
/** Best-available warm session for a domain (in-memory first, then the external store). */
|
|
68
|
+
export declare function getWarmSession(domain: string): Promise<WarmSession | null>;
|
|
69
|
+
/**
|
|
70
|
+
* Registers a solved session as warm. Returns the sandboxIds evicted by the
|
|
71
|
+
* cap — the caller must stop those sandboxes so idle compute does not linger.
|
|
72
|
+
*/
|
|
73
|
+
export declare function saveWarmSession(session: WarmSession): Promise<string[]>;
|
|
74
|
+
export declare function deleteWarmSession(domain: string): Promise<void>;
|
|
75
|
+
/** Marks a domain as known to serve JS challenges (skips the curl tier next time). */
|
|
76
|
+
export declare function markDomainChallenged(domain: string): Promise<void>;
|
|
77
|
+
export declare function isDomainChallenged(domain: string): Promise<boolean>;
|
|
78
|
+
/**
|
|
79
|
+
* Marks a domain as needing the residential-proxy tier (Tier P1): every
|
|
80
|
+
* datacenter egress attempt is blocked by IP reputation (DataDome et al), so
|
|
81
|
+
* future ladder runs skip straight to the proxy hop.
|
|
82
|
+
*/
|
|
83
|
+
export declare function markDomainProxyNeeded(domain: string): Promise<void>;
|
|
84
|
+
export declare function isDomainProxyNeeded(domain: string): Promise<boolean>;
|
|
85
|
+
/**
|
|
86
|
+
* Marks a domain as needing the render-provider tier (Tier R): the native
|
|
87
|
+
* fetch ladder passes the bot check but the page's content only appears after
|
|
88
|
+
* JS renders, so future runs skip the ladder and go provider-first.
|
|
89
|
+
*/
|
|
90
|
+
export declare function markDomainRenderNeeded(domain: string): Promise<void>;
|
|
91
|
+
export declare function isDomainRenderNeeded(domain: string): Promise<boolean>;
|
|
92
|
+
/** Test helper: resets all in-memory state (external stores are untouched). */
|
|
93
|
+
export declare function resetSessionStateForTests(): void;
|
|
@@ -0,0 +1,250 @@
|
|
|
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
|
+
/** How long a domain stays on the residential-proxy memo (Tier P1). */
|
|
31
|
+
export const PROXY_MEMO_TTL_MS = 60 * 60_000;
|
|
32
|
+
/** How long a domain stays on the render-provider memo (Tier R). */
|
|
33
|
+
export const RENDER_MEMO_TTL_MS = 12 * 60 * 60_000;
|
|
34
|
+
let external = null;
|
|
35
|
+
export function registerExternalSessionStore(store) {
|
|
36
|
+
external = store;
|
|
37
|
+
}
|
|
38
|
+
const warm = new Map();
|
|
39
|
+
const challenged = new Map();
|
|
40
|
+
const proxyNeeded = new Map();
|
|
41
|
+
const renderNeeded = new Map();
|
|
42
|
+
function reap(now = Date.now()) {
|
|
43
|
+
for (const [d, s] of warm)
|
|
44
|
+
if (s.expiresAt <= now)
|
|
45
|
+
warm.delete(d);
|
|
46
|
+
for (const [d, t] of challenged)
|
|
47
|
+
if (t <= now)
|
|
48
|
+
challenged.delete(d);
|
|
49
|
+
for (const [d, t] of proxyNeeded)
|
|
50
|
+
if (t <= now)
|
|
51
|
+
proxyNeeded.delete(d);
|
|
52
|
+
for (const [d, t] of renderNeeded)
|
|
53
|
+
if (t <= now)
|
|
54
|
+
renderNeeded.delete(d);
|
|
55
|
+
}
|
|
56
|
+
/** Serializes captured browser cookies into a `Cookie:` header value (drops expired). */
|
|
57
|
+
export function cookieHeaderFrom(cookies) {
|
|
58
|
+
const now = Date.now() / 1000;
|
|
59
|
+
return cookies
|
|
60
|
+
.filter((c) => c.name && c.value && (!c.expires || c.expires > now))
|
|
61
|
+
.map((c) => `${c.name}=${c.value}`)
|
|
62
|
+
.join("; ");
|
|
63
|
+
}
|
|
64
|
+
/** Best-available warm session for a domain (in-memory first, then the external store). */
|
|
65
|
+
export async function getWarmSession(domain) {
|
|
66
|
+
if (!domain)
|
|
67
|
+
return null;
|
|
68
|
+
reap();
|
|
69
|
+
const local = warm.get(domain);
|
|
70
|
+
if (local && local.expiresAt > Date.now())
|
|
71
|
+
return local;
|
|
72
|
+
if (local)
|
|
73
|
+
warm.delete(domain);
|
|
74
|
+
if (external) {
|
|
75
|
+
try {
|
|
76
|
+
const stored = await external.getWarmSession(domain);
|
|
77
|
+
if (stored && stored.expiresAt > Date.now()) {
|
|
78
|
+
warm.set(domain, stored);
|
|
79
|
+
return stored;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
/* store unavailable */
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Registers a solved session as warm. Returns the sandboxIds evicted by the
|
|
90
|
+
* cap — the caller must stop those sandboxes so idle compute does not linger.
|
|
91
|
+
*/
|
|
92
|
+
export async function saveWarmSession(session) {
|
|
93
|
+
if (!session.domain)
|
|
94
|
+
return [];
|
|
95
|
+
reap();
|
|
96
|
+
warm.delete(session.domain);
|
|
97
|
+
warm.set(session.domain, { ...session });
|
|
98
|
+
const evicted = [];
|
|
99
|
+
while (warm.size > WARM_CAP) {
|
|
100
|
+
const [d, s] = warm.entries().next().value;
|
|
101
|
+
warm.delete(d);
|
|
102
|
+
if (s.sandboxId && s.sandboxId !== session.sandboxId)
|
|
103
|
+
evicted.push(s.sandboxId);
|
|
104
|
+
}
|
|
105
|
+
if (external) {
|
|
106
|
+
try {
|
|
107
|
+
await external.saveWarmSession(session);
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
/* store unavailable */
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return evicted;
|
|
114
|
+
}
|
|
115
|
+
export async function deleteWarmSession(domain) {
|
|
116
|
+
if (!domain)
|
|
117
|
+
return;
|
|
118
|
+
warm.delete(domain);
|
|
119
|
+
if (external) {
|
|
120
|
+
try {
|
|
121
|
+
await external.deleteWarmSession(domain);
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
/* store unavailable */
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
/** Marks a domain as known to serve JS challenges (skips the curl tier next time). */
|
|
129
|
+
export async function markDomainChallenged(domain) {
|
|
130
|
+
if (!domain)
|
|
131
|
+
return;
|
|
132
|
+
challenged.set(domain, Date.now() + CHALLENGE_MEMO_TTL_MS);
|
|
133
|
+
if (external) {
|
|
134
|
+
try {
|
|
135
|
+
await external.markDomainChallenged(domain);
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
/* store unavailable */
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
export async function isDomainChallenged(domain) {
|
|
143
|
+
if (!domain)
|
|
144
|
+
return false;
|
|
145
|
+
reap();
|
|
146
|
+
const until = challenged.get(domain);
|
|
147
|
+
if (until && until > Date.now())
|
|
148
|
+
return true;
|
|
149
|
+
if (until)
|
|
150
|
+
challenged.delete(domain);
|
|
151
|
+
if (external) {
|
|
152
|
+
try {
|
|
153
|
+
if (await external.isDomainChallenged(domain)) {
|
|
154
|
+
challenged.set(domain, Date.now() + CHALLENGE_MEMO_TTL_MS);
|
|
155
|
+
return true;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
/* store unavailable */
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return false;
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Marks a domain as needing the residential-proxy tier (Tier P1): every
|
|
166
|
+
* datacenter egress attempt is blocked by IP reputation (DataDome et al), so
|
|
167
|
+
* future ladder runs skip straight to the proxy hop.
|
|
168
|
+
*/
|
|
169
|
+
export async function markDomainProxyNeeded(domain) {
|
|
170
|
+
if (!domain)
|
|
171
|
+
return;
|
|
172
|
+
proxyNeeded.set(domain, Date.now() + PROXY_MEMO_TTL_MS);
|
|
173
|
+
if (external?.markDomainProxyNeeded) {
|
|
174
|
+
try {
|
|
175
|
+
await external.markDomainProxyNeeded(domain);
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
/* store unavailable */
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
export async function isDomainProxyNeeded(domain) {
|
|
183
|
+
if (!domain)
|
|
184
|
+
return false;
|
|
185
|
+
reap();
|
|
186
|
+
const until = proxyNeeded.get(domain);
|
|
187
|
+
if (until && until > Date.now())
|
|
188
|
+
return true;
|
|
189
|
+
if (until)
|
|
190
|
+
proxyNeeded.delete(domain);
|
|
191
|
+
if (external?.isDomainProxyNeeded) {
|
|
192
|
+
try {
|
|
193
|
+
if (await external.isDomainProxyNeeded(domain)) {
|
|
194
|
+
proxyNeeded.set(domain, Date.now() + PROXY_MEMO_TTL_MS);
|
|
195
|
+
return true;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
catch {
|
|
199
|
+
/* store unavailable */
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return false;
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Marks a domain as needing the render-provider tier (Tier R): the native
|
|
206
|
+
* fetch ladder passes the bot check but the page's content only appears after
|
|
207
|
+
* JS renders, so future runs skip the ladder and go provider-first.
|
|
208
|
+
*/
|
|
209
|
+
export async function markDomainRenderNeeded(domain) {
|
|
210
|
+
if (!domain)
|
|
211
|
+
return;
|
|
212
|
+
renderNeeded.set(domain, Date.now() + RENDER_MEMO_TTL_MS);
|
|
213
|
+
if (external?.markDomainRenderNeeded) {
|
|
214
|
+
try {
|
|
215
|
+
await external.markDomainRenderNeeded(domain);
|
|
216
|
+
}
|
|
217
|
+
catch {
|
|
218
|
+
/* store unavailable */
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
export async function isDomainRenderNeeded(domain) {
|
|
223
|
+
if (!domain)
|
|
224
|
+
return false;
|
|
225
|
+
reap();
|
|
226
|
+
const until = renderNeeded.get(domain);
|
|
227
|
+
if (until && until > Date.now())
|
|
228
|
+
return true;
|
|
229
|
+
if (until)
|
|
230
|
+
renderNeeded.delete(domain);
|
|
231
|
+
if (external?.isDomainRenderNeeded) {
|
|
232
|
+
try {
|
|
233
|
+
if (await external.isDomainRenderNeeded(domain)) {
|
|
234
|
+
renderNeeded.set(domain, Date.now() + RENDER_MEMO_TTL_MS);
|
|
235
|
+
return true;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
catch {
|
|
239
|
+
/* store unavailable */
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return false;
|
|
243
|
+
}
|
|
244
|
+
/** Test helper: resets all in-memory state (external stores are untouched). */
|
|
245
|
+
export function resetSessionStateForTests() {
|
|
246
|
+
warm.clear();
|
|
247
|
+
challenged.clear();
|
|
248
|
+
proxyNeeded.clear();
|
|
249
|
+
renderNeeded.clear();
|
|
250
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { type RemoteProvider } from "../interact/remote.js";
|
|
2
|
+
export interface StealthDoc {
|
|
3
|
+
url: string;
|
|
4
|
+
ok: boolean;
|
|
5
|
+
status?: number | null;
|
|
6
|
+
contentType?: string;
|
|
7
|
+
body?: string;
|
|
8
|
+
finalUrl?: string;
|
|
9
|
+
title?: string;
|
|
10
|
+
blocked?: boolean;
|
|
11
|
+
error?: string;
|
|
12
|
+
}
|
|
13
|
+
export interface StealthResult {
|
|
14
|
+
/** True when the entry page passed the bot check. */
|
|
15
|
+
solved: boolean;
|
|
16
|
+
entry: StealthDoc | null;
|
|
17
|
+
docs: StealthDoc[];
|
|
18
|
+
durationMs: number;
|
|
19
|
+
error?: string;
|
|
20
|
+
/** Set when a remote provider (e.g. Browser Use) hosted the browser. */
|
|
21
|
+
provider?: RemoteProvider;
|
|
22
|
+
/** Provider charge for the session in USD, when it reported one. */
|
|
23
|
+
providerCostUsd?: number;
|
|
24
|
+
}
|
|
25
|
+
export interface StealthItem {
|
|
26
|
+
url: string;
|
|
27
|
+
/** Read as a raw document (robots.txt, sitemap XML) instead of rendering it. */
|
|
28
|
+
raw?: boolean;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* True when a real browser can run here: our own (sandbox in prod, local
|
|
32
|
+
* Playwright in dev) or a remote provider. A remote browser still needs the
|
|
33
|
+
* sandbox or local Playwright to run the step script against it.
|
|
34
|
+
*/
|
|
35
|
+
export declare function stealthAvailable(): Promise<boolean>;
|
|
36
|
+
/** True when this fetched document is a bot check, not the real content. */
|
|
37
|
+
export declare function isBlockedResponse(status: number | null | undefined, body: string): boolean;
|
|
38
|
+
/**
|
|
39
|
+
* Opens `entryUrl` in a real browser, waits for the bot check to clear, then
|
|
40
|
+
* reads `items` in order through that session. One browser per call.
|
|
41
|
+
*/
|
|
42
|
+
export declare function stealthFetch(entryUrl: string, items: StealthItem[], opts?: {
|
|
43
|
+
budgetMs?: number;
|
|
44
|
+
solveMs?: number;
|
|
45
|
+
followSitemaps?: number;
|
|
46
|
+
/** Allow the paid remote provider. Off for unbilled (anonymous) runs. Default true. */
|
|
47
|
+
remote?: boolean;
|
|
48
|
+
/** Keep the solved session warm (sandbox left running + cookies saved) for repeat queries. */
|
|
49
|
+
keepWarm?: boolean;
|
|
50
|
+
/** Tier P2: egress the stealth browser through this residential proxy URL. */
|
|
51
|
+
proxy?: string;
|
|
52
|
+
}): Promise<StealthResult>;
|
|
53
|
+
/** Extracts `<loc>` entries from a sitemap or sitemap index. */
|
|
54
|
+
export declare function parseSitemapLocs(xml: string): {
|
|
55
|
+
pages: string[];
|
|
56
|
+
sitemaps: string[];
|
|
57
|
+
};
|
|
58
|
+
/** Sitemap URLs declared in robots.txt. */
|
|
59
|
+
export declare function sitemapsFromRobots(robots: string): string[];
|
|
60
|
+
/**
|
|
61
|
+
* Finds a blocked site's pages through one stealth session: robots.txt and the
|
|
62
|
+
* usual sitemap paths, following robots-declared and child sitemaps in the same
|
|
63
|
+
* session. Also returns the rendered entry page so its links count when there
|
|
64
|
+
* is no sitemap.
|
|
65
|
+
*/
|
|
66
|
+
export declare function stealthDiscover(entryUrl: string, opts?: {
|
|
67
|
+
budgetMs?: number;
|
|
68
|
+
sitemapUrls?: string[];
|
|
69
|
+
remote?: boolean;
|
|
70
|
+
}): Promise<{
|
|
71
|
+
solved: boolean;
|
|
72
|
+
sitemapPages: string[];
|
|
73
|
+
sitemapsRead: string[];
|
|
74
|
+
entry: StealthDoc | null;
|
|
75
|
+
durationMs: number;
|
|
76
|
+
error?: string;
|
|
77
|
+
providerCostUsd?: number;
|
|
78
|
+
}>;
|
|
79
|
+
/** Page URLs from every readable sitemap among `docs` (robots.txt and blocked docs are skipped). */
|
|
80
|
+
export declare function collectSitemapPages(docs: StealthDoc[]): {
|
|
81
|
+
pages: string[];
|
|
82
|
+
sitemapsRead: string[];
|
|
83
|
+
};
|