@webcrawl/mcp 1.4.1 → 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 +8 -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 +7 -0
- package/package.json +11 -3
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
export interface ScrapeOptions {
|
|
2
|
+
url: string;
|
|
3
|
+
formats?: Array<"markdown" | "html" | "links">;
|
|
4
|
+
retainImages?: "all" | "none";
|
|
5
|
+
removeSelectors?: string[];
|
|
6
|
+
render?: boolean;
|
|
7
|
+
timeout?: number;
|
|
8
|
+
bypassCache?: boolean;
|
|
9
|
+
cache?: boolean;
|
|
10
|
+
}
|
|
11
|
+
export interface ScrapeResult {
|
|
12
|
+
success: boolean;
|
|
13
|
+
url: string;
|
|
14
|
+
title: string;
|
|
15
|
+
markdown: string;
|
|
16
|
+
html?: string;
|
|
17
|
+
links?: string[];
|
|
18
|
+
statusCode: number;
|
|
19
|
+
contentHash: string;
|
|
20
|
+
tier: "cache" | "static" | "sandbox" | "cloudflare" | "browser" | "impersonate" | "proxy-impersonate";
|
|
21
|
+
latencyMs: number;
|
|
22
|
+
error?: string;
|
|
23
|
+
}
|
|
24
|
+
export declare function getBrowserHeaders(targetUrl: string, extraHeaders?: Record<string, string>): Record<string, string>;
|
|
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;
|
|
39
|
+
export declare function isAntiBotChallenge(html: string, status: number): boolean;
|
|
40
|
+
/** Transport tier that won the last fetch ladder run (for result evidence). */
|
|
41
|
+
export declare function lastFetchTier(): "impit-tls" | "impersonate" | "proxy-impersonate" | "fetch";
|
|
42
|
+
/** Bytes transferred through the residential proxy on the last ladder run (bandwidth accounting). */
|
|
43
|
+
export declare function lastProxyBytesCount(): number;
|
|
44
|
+
/**
|
|
45
|
+
* Residential proxy URL from env, or null when the tier is disabled.
|
|
46
|
+
* Generic: a full `WEBCRAWL_PROXY_URL` (`http://user:pass@host:port`) or the
|
|
47
|
+
* split `WEBCRAWL_PROXY_USER` + `WEBCRAWL_PROXY_PWD` pair, with the gateway
|
|
48
|
+
* host/port overridable via `WEBCRAWL_PROXY_HOST` / `WEBCRAWL_PROXY_PORT`.
|
|
49
|
+
* `DECODO_*` equivalents are honored as a legacy fallback so existing
|
|
50
|
+
* deployments keep working (their default gateway is `gate.decodo.com:7000`).
|
|
51
|
+
*/
|
|
52
|
+
export declare function residentialProxyUrl(): string | null;
|
|
53
|
+
/**
|
|
54
|
+
* Fetches a URL using `impit` (BoringSSL Chrome 136 TLS JA3/JA4 + HTTP/2 fingerprint impersonation)sonation)
|
|
55
|
+
* so Cloudflare Bot Management and Vercel Security Checkpoint treat requests as real Chrome.
|
|
56
|
+
* Falls back to standard `fetch` if `impit` is unavailable on the current platform.
|
|
57
|
+
* Every redirect hop is checked against the SSRF guard.
|
|
58
|
+
*/
|
|
59
|
+
export declare function fetchWithTlsImpersonation(url: string, timeoutMs?: number): Promise<{
|
|
60
|
+
status: number;
|
|
61
|
+
html: string;
|
|
62
|
+
headers: Record<string, string>;
|
|
63
|
+
}>;
|
|
64
|
+
export interface FetchedResource {
|
|
65
|
+
status: number;
|
|
66
|
+
headers: Record<string, string>;
|
|
67
|
+
/** Declared content type without parameters, lower-cased ("" when missing). */
|
|
68
|
+
contentType: string;
|
|
69
|
+
body: Uint8Array;
|
|
70
|
+
/** Final URL after redirects when the transport reports it. */
|
|
71
|
+
url: string;
|
|
72
|
+
}
|
|
73
|
+
export declare class ResourceTooLargeError extends Error {
|
|
74
|
+
readonly bytes: number;
|
|
75
|
+
readonly maxBytes: number;
|
|
76
|
+
constructor(bytes: number, maxBytes: number);
|
|
77
|
+
}
|
|
78
|
+
/** Largest download accepted for any file (the per-type caps in src/parse/limits.ts are tighter). */
|
|
79
|
+
export declare const MAX_RESOURCE_BYTES: number;
|
|
80
|
+
/**
|
|
81
|
+
* Binary-safe GET. Same TLS impersonation as `fetchWithTlsImpersonation`, but keeps
|
|
82
|
+
* the body as bytes so PDFs, Office files, images and media survive intact.
|
|
83
|
+
*/
|
|
84
|
+
export declare function fetchResource(url: string, options?: {
|
|
85
|
+
timeoutMs?: number;
|
|
86
|
+
maxBytes?: number;
|
|
87
|
+
}): Promise<FetchedResource>;
|
|
88
|
+
/**
|
|
89
|
+
* Detects whether an HTML response is an empty SPA shell or a Cloudflare/WAF challenge page
|
|
90
|
+
* that requires headless browser / Vercel Sandbox escalation.
|
|
91
|
+
*/
|
|
92
|
+
export declare function needsBrowserEscalation(status: number, html: string, markdown: string): boolean;
|
|
@@ -0,0 +1,494 @@
|
|
|
1
|
+
import { followSafely, UnsafeUrlError } from "./safe-url.js";
|
|
2
|
+
import { isDomainProxyNeeded, markDomainProxyNeeded, } from "./session.js";
|
|
3
|
+
const domainCookies = new Map();
|
|
4
|
+
const domainUserAgents = new Map();
|
|
5
|
+
const BROWSER_USER_AGENTS = [
|
|
6
|
+
"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",
|
|
7
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36",
|
|
8
|
+
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36",
|
|
9
|
+
];
|
|
10
|
+
export function getBrowserHeaders(targetUrl, extraHeaders) {
|
|
11
|
+
const parsed = new URL(targetUrl);
|
|
12
|
+
const ua = domainUserAgents.get(parsed.hostname) ?? BROWSER_USER_AGENTS[Math.abs(hashCode(targetUrl)) % BROWSER_USER_AGENTS.length];
|
|
13
|
+
let hostCookie;
|
|
14
|
+
try {
|
|
15
|
+
hostCookie = domainCookies.get(parsed.hostname);
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
// ignore invalid URL
|
|
19
|
+
}
|
|
20
|
+
return {
|
|
21
|
+
"User-Agent": ua,
|
|
22
|
+
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8",
|
|
23
|
+
"Accept-Language": "en-US,en;q=0.9",
|
|
24
|
+
"Accept-Encoding": "gzip, deflate, br",
|
|
25
|
+
"Cache-Control": "no-cache",
|
|
26
|
+
Pragma: "no-cache",
|
|
27
|
+
"Sec-Ch-Ua": '"Chromium";v="136", "Google Chrome";v="136", "Not.A/Brand";v="99"',
|
|
28
|
+
"Sec-Ch-Ua-Mobile": "?0",
|
|
29
|
+
"Sec-Ch-Ua-Platform": '"macOS"',
|
|
30
|
+
"Sec-Fetch-Dest": "document",
|
|
31
|
+
"Sec-Fetch-Mode": "navigate",
|
|
32
|
+
"Sec-Fetch-Site": "none",
|
|
33
|
+
"Sec-Fetch-User": "?1",
|
|
34
|
+
"Upgrade-Insecure-Requests": "1",
|
|
35
|
+
...(hostCookie ? { Cookie: hostCookie } : {}),
|
|
36
|
+
...extraHeaders,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
export function setDomainCookie(hostname, cookieHeader) {
|
|
40
|
+
if (hostname && cookieHeader) {
|
|
41
|
+
domainCookies.set(hostname, cookieHeader);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* User-Agent paired with a domain's replayed session cookies. Cloudflare's
|
|
46
|
+
* `cf_clearance` (and similar bound tokens) only validate when the exact UA
|
|
47
|
+
* that solved the challenge is reused, so replay sets both together.
|
|
48
|
+
*/
|
|
49
|
+
export function setDomainUserAgent(hostname, userAgent) {
|
|
50
|
+
if (hostname && userAgent) {
|
|
51
|
+
domainUserAgents.set(hostname, userAgent);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
function hashCode(str) {
|
|
55
|
+
let hash = 0;
|
|
56
|
+
for (let i = 0; i < str.length; i++) {
|
|
57
|
+
hash = (hash << 5) - hash + str.charCodeAt(i);
|
|
58
|
+
hash |= 0;
|
|
59
|
+
}
|
|
60
|
+
return hash;
|
|
61
|
+
}
|
|
62
|
+
/** Markers that only appear on an AWS WAF challenge interstitial. */
|
|
63
|
+
const AWS_WAF_CHALLENGE_MARKERS = [
|
|
64
|
+
"awswafcookiedomainlist",
|
|
65
|
+
"awswafintegration",
|
|
66
|
+
"aws-waf-token",
|
|
67
|
+
'id="challenge-container"',
|
|
68
|
+
"/__challenge_",
|
|
69
|
+
];
|
|
70
|
+
/**
|
|
71
|
+
* True when an HTML response (with optional status) is an AWS WAF challenge
|
|
72
|
+
* interstitial: a marker match proves it regardless of status; otherwise a
|
|
73
|
+
* documented HTTP 202 whose payload is a `<script>`-only HTML shell with zero
|
|
74
|
+
* visible text (APIs legitimately return 202 too, so status alone is not proof).
|
|
75
|
+
*/
|
|
76
|
+
export function isAwsWafChallenge(html, status) {
|
|
77
|
+
const lower = String(html ?? "").slice(0, 8000).toLowerCase();
|
|
78
|
+
if (AWS_WAF_CHALLENGE_MARKERS.some((m) => lower.includes(m)))
|
|
79
|
+
return true;
|
|
80
|
+
if (status === 202) {
|
|
81
|
+
if (!lower.includes("<html"))
|
|
82
|
+
return false;
|
|
83
|
+
const visibleText = lower
|
|
84
|
+
.slice(0, 8000)
|
|
85
|
+
.replace(/<script[\s\S]*?<\/script>/g, "")
|
|
86
|
+
.replace(/<noscript[\s\S]*?<\/noscript>/g, "")
|
|
87
|
+
.replace(/<style[\s\S]*?<\/style>/g, "")
|
|
88
|
+
.replace(/<[^>]+>/g, " ")
|
|
89
|
+
.trim();
|
|
90
|
+
return visibleText.length === 0;
|
|
91
|
+
}
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
export function isAntiBotChallenge(html, status) {
|
|
95
|
+
if (status === 403 || status === 429 || status === 503) {
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
const raw = String(html ?? "");
|
|
99
|
+
// Reddit (and similar) hide this copy after tens of KB of CSS + a data-URI PNG.
|
|
100
|
+
if (/blocked by network security/i.test(raw)) {
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
// Head slice catches title/meta markers on ordinary challenge interstitials.
|
|
104
|
+
const lower = raw.slice(0, 8000).toLowerCase();
|
|
105
|
+
if (lower.includes("cf-browser-verification") ||
|
|
106
|
+
lower.includes("challenge-platform") ||
|
|
107
|
+
lower.includes("just a moment...") ||
|
|
108
|
+
lower.includes("vercel security checkpoint") ||
|
|
109
|
+
lower.includes("_vercel/security") ||
|
|
110
|
+
lower.includes("enable javascript and cookies to continue") ||
|
|
111
|
+
lower.includes("_cf_chl_opt")) {
|
|
112
|
+
return true;
|
|
113
|
+
}
|
|
114
|
+
// IP-reputation interstitials (stubhub, expedia) that pass the TLS gate
|
|
115
|
+
// but gate datacenter IPs behind a "enable JS / are you human" shell.
|
|
116
|
+
// These can arrive cached or served with status 200, so text markers are
|
|
117
|
+
// required on top of the 403/429 status check above.
|
|
118
|
+
if (lower.includes("enable javascript and disable any ad blocker") ||
|
|
119
|
+
lower.includes("show us your human side") ||
|
|
120
|
+
lower.includes("we can't tell if you're a human or a bot") ||
|
|
121
|
+
lower.includes("we can’t tell if you’re a human or a bot")) {
|
|
122
|
+
return true;
|
|
123
|
+
}
|
|
124
|
+
// AWS WAF serves its invisible JS challenge as HTTP 202 with none of the
|
|
125
|
+
// Cloudflare/Vercel markers, so those pages used to pass as real content.
|
|
126
|
+
return isAwsWafChallenge(html, status);
|
|
127
|
+
}
|
|
128
|
+
let sharedImpit = null;
|
|
129
|
+
/** Tier that won the most recent rawGet ladder ("impit-tls" → "impersonate" → "proxy-impersonate" → "fetch"). */
|
|
130
|
+
let lastTier = "impit-tls";
|
|
131
|
+
/** Bytes pulled through the residential proxy on the most recent ladder run. */
|
|
132
|
+
let lastProxyBytes = 0;
|
|
133
|
+
/** Transport tier that won the last fetch ladder run (for result evidence). */
|
|
134
|
+
export function lastFetchTier() {
|
|
135
|
+
return lastTier;
|
|
136
|
+
}
|
|
137
|
+
/** Bytes transferred through the residential proxy on the last ladder run (bandwidth accounting). */
|
|
138
|
+
export function lastProxyBytesCount() {
|
|
139
|
+
return lastProxyBytes;
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Residential proxy URL from env, or null when the tier is disabled.
|
|
143
|
+
* Generic: a full `WEBCRAWL_PROXY_URL` (`http://user:pass@host:port`) or the
|
|
144
|
+
* split `WEBCRAWL_PROXY_USER` + `WEBCRAWL_PROXY_PWD` pair, with the gateway
|
|
145
|
+
* host/port overridable via `WEBCRAWL_PROXY_HOST` / `WEBCRAWL_PROXY_PORT`.
|
|
146
|
+
* `DECODO_*` equivalents are honored as a legacy fallback so existing
|
|
147
|
+
* deployments keep working (their default gateway is `gate.decodo.com:7000`).
|
|
148
|
+
*/
|
|
149
|
+
export function residentialProxyUrl() {
|
|
150
|
+
const direct = process.env.WEBCRAWL_PROXY_URL?.trim() || process.env.DECODO_URL?.trim();
|
|
151
|
+
if (direct)
|
|
152
|
+
return direct;
|
|
153
|
+
const user = process.env.WEBCRAWL_PROXY_USER?.trim() || process.env.DECODO_USER?.trim();
|
|
154
|
+
const pwd = process.env.WEBCRAWL_PROXY_PWD?.trim() || process.env.DECODO_PWD?.trim();
|
|
155
|
+
if (user && pwd) {
|
|
156
|
+
const host = process.env.WEBCRAWL_PROXY_HOST?.trim() || "gate.decodo.com";
|
|
157
|
+
const port = process.env.WEBCRAWL_PROXY_PORT?.trim() || "7000";
|
|
158
|
+
return `http://${encodeURIComponent(user)}:${encodeURIComponent(pwd)}@${host}:${port}`;
|
|
159
|
+
}
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
/** One request, redirects NOT followed (callers validate each hop via followSafely). */
|
|
163
|
+
async function rawGet(url, reqHeaders, timeoutMs, maxBytes) {
|
|
164
|
+
const read = async (status, headers, getBuf) => {
|
|
165
|
+
const isRedirect = status >= 300 && status < 400 && !!headers["location"];
|
|
166
|
+
if (!isRedirect && maxBytes !== undefined)
|
|
167
|
+
checkDeclaredLength(headers, maxBytes);
|
|
168
|
+
return { status, headers, body: isRedirect ? new ArrayBuffer(0) : await getBuf() };
|
|
169
|
+
};
|
|
170
|
+
lastProxyBytes = 0;
|
|
171
|
+
// Tier P1 fast path: domains memoized as IP-blocked (DataDome) skip the
|
|
172
|
+
// datacenter attempts entirely and go straight to the residential proxy —
|
|
173
|
+
// saves latency and never burns proxy bytes on a doomed direct hop first.
|
|
174
|
+
const proxyUrl = residentialProxyUrl();
|
|
175
|
+
let host = "";
|
|
176
|
+
try {
|
|
177
|
+
host = new URL(url).hostname;
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
/* let the direct hops surface the URL error */
|
|
181
|
+
}
|
|
182
|
+
if (proxyUrl && host && (await isDomainProxyNeeded(host).catch(() => false))) {
|
|
183
|
+
const proxyHop = await rawGetImpersonateProxy(url, reqHeaders, timeoutMs, maxBytes, proxyUrl);
|
|
184
|
+
if (proxyHop) {
|
|
185
|
+
lastTier = "proxy-impersonate";
|
|
186
|
+
return proxyHop;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
try {
|
|
190
|
+
if (!sharedImpit) {
|
|
191
|
+
const impitMod = await import("impit");
|
|
192
|
+
sharedImpit = new impitMod.Impit({
|
|
193
|
+
browser: "chrome",
|
|
194
|
+
ignoreTlsErrors: true,
|
|
195
|
+
timeout: timeoutMs,
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
const res = await sharedImpit.fetch(url, {
|
|
199
|
+
method: "GET",
|
|
200
|
+
headers: reqHeaders,
|
|
201
|
+
timeout: timeoutMs,
|
|
202
|
+
redirect: "manual",
|
|
203
|
+
});
|
|
204
|
+
const hop = await read(res.status, headersToObject(res.headers), () => res.arrayBuffer());
|
|
205
|
+
// AWS-WAF-style blocks (403/429/challenge shells) on the impit tier
|
|
206
|
+
// (Chrome 136) retry once on the curl-impersonate tier (Chrome 150),
|
|
207
|
+
// which passes WAFs that block the older fingerprint.
|
|
208
|
+
if (isAntiBotChallenge(new TextDecoder().decode(hop.body).slice(0, 8000), hop.status)) {
|
|
209
|
+
const impersonateHop = await rawGetImpersonate(url, reqHeaders, timeoutMs, maxBytes);
|
|
210
|
+
if (impersonateHop) {
|
|
211
|
+
if (isAntiBotChallenge(new TextDecoder().decode(impersonateHop.body).slice(0, 8000), impersonateHop.status)) {
|
|
212
|
+
// Direct datacenter egress is IP-blocked (DataDome): last cheap try
|
|
213
|
+
// is the residential proxy hop before browser escalation. Costs
|
|
214
|
+
// ~100-300KB of proxy bandwidth instead of a 5-15MB page load.
|
|
215
|
+
if (proxyUrl) {
|
|
216
|
+
const proxyHop = await rawGetImpersonateProxy(url, reqHeaders, timeoutMs, maxBytes, proxyUrl);
|
|
217
|
+
if (proxyHop) {
|
|
218
|
+
lastTier = "proxy-impersonate";
|
|
219
|
+
return proxyHop;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
lastTier = "impersonate";
|
|
224
|
+
return impersonateHop;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
lastTier = "impit-tls";
|
|
228
|
+
return hop;
|
|
229
|
+
}
|
|
230
|
+
catch (err) {
|
|
231
|
+
if (err instanceof ResourceTooLargeError || err instanceof UnsafeUrlError)
|
|
232
|
+
throw err;
|
|
233
|
+
// impit unavailable/failed: curl-impersonate tier, then plain fetch.
|
|
234
|
+
const impersonateHop = await rawGetImpersonate(url, reqHeaders, timeoutMs, maxBytes);
|
|
235
|
+
if (impersonateHop) {
|
|
236
|
+
lastTier = "impersonate";
|
|
237
|
+
return impersonateHop;
|
|
238
|
+
}
|
|
239
|
+
const res = await fetch(url, {
|
|
240
|
+
headers: reqHeaders,
|
|
241
|
+
redirect: "manual",
|
|
242
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
243
|
+
});
|
|
244
|
+
lastTier = "fetch";
|
|
245
|
+
return await read(res.status, headersToObject(res.headers), () => res.arrayBuffer());
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* One GET hop with the curl-impersonate tier (Chrome 150 TLS/HTTP2 fingerprint).
|
|
250
|
+
* Used by `rawGet` when the impit hop is blocked (403/429/challenge) — newer
|
|
251
|
+
* fingerprints pass AWS WAF domains (stubhub, expedia) that block Chrome 136.
|
|
252
|
+
* Falls back to plain `fetch` when the binary is unavailable.
|
|
253
|
+
*/
|
|
254
|
+
async function rawGetImpersonate(url, reqHeaders, timeoutMs, maxBytes) {
|
|
255
|
+
let impersonate;
|
|
256
|
+
try {
|
|
257
|
+
impersonate = await import("@webcrawl/curl-impersonate");
|
|
258
|
+
}
|
|
259
|
+
catch {
|
|
260
|
+
return null;
|
|
261
|
+
}
|
|
262
|
+
// The chrome150 profile's own identity headers MUST survive (profileHeaders
|
|
263
|
+
// merges extras last, so a stale UA would override the profile UA and the
|
|
264
|
+
// UA/TLS mismatch is exactly what DataDome/AWS WAF block). Keep cookies and
|
|
265
|
+
// referer, drop UA + sec-ch/client-hint identity headers.
|
|
266
|
+
const extra = {};
|
|
267
|
+
for (const [key, value] of Object.entries(reqHeaders)) {
|
|
268
|
+
const lower = key.toLowerCase();
|
|
269
|
+
if (lower === "user-agent" ||
|
|
270
|
+
lower.startsWith("sec-ch-ua") ||
|
|
271
|
+
lower.startsWith("sec-fetch-") ||
|
|
272
|
+
lower === "upgrade-insecure-requests" ||
|
|
273
|
+
lower === "priority") {
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
extra[key] = value;
|
|
277
|
+
}
|
|
278
|
+
try {
|
|
279
|
+
const res = await impersonate.impersonateFetch(url, {
|
|
280
|
+
headers: extra,
|
|
281
|
+
timeoutMs,
|
|
282
|
+
maxBytes,
|
|
283
|
+
});
|
|
284
|
+
const headers = res.headers;
|
|
285
|
+
const isRedirect = res.status >= 300 && res.status < 400 && !!headers["location"];
|
|
286
|
+
if (!isRedirect && maxBytes !== undefined)
|
|
287
|
+
checkDeclaredLength(headers, maxBytes);
|
|
288
|
+
return {
|
|
289
|
+
status: res.status,
|
|
290
|
+
headers,
|
|
291
|
+
body: isRedirect ? new ArrayBuffer(0) : res.body.buffer,
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
catch (err) {
|
|
295
|
+
if (err instanceof UnsafeUrlError)
|
|
296
|
+
throw err;
|
|
297
|
+
return null; // tier unavailable or fetch failed — caller falls back
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
/**
|
|
301
|
+
* Tier P1 — one GET hop with curl-impersonate through the residential proxy
|
|
302
|
+
* (Decodo or any `http://user:pass@host:port` HTTP proxy). The target sees a
|
|
303
|
+
* clean residential egress IP while the TLS/HTTP2 fingerprint stays a real
|
|
304
|
+
* Chrome's. Only reached for domains whose datacenter egress is proven
|
|
305
|
+
* IP-blocked, so the domain is memoized and later ladder runs skip the
|
|
306
|
+
* doomed direct attempts entirely. Gzipped HTML only (~100-300KB/request);
|
|
307
|
+
* a 2MB byte cap keeps a runaway response from draining proxy bandwidth.
|
|
308
|
+
*/
|
|
309
|
+
async function rawGetImpersonateProxy(url, reqHeaders, timeoutMs, maxBytes, proxyUrl) {
|
|
310
|
+
let host = "";
|
|
311
|
+
try {
|
|
312
|
+
host = new URL(url).hostname;
|
|
313
|
+
}
|
|
314
|
+
catch {
|
|
315
|
+
/* URL errors surface via the direct hops */
|
|
316
|
+
}
|
|
317
|
+
if (host)
|
|
318
|
+
await markDomainProxyNeeded(host).catch(() => { });
|
|
319
|
+
let impersonate;
|
|
320
|
+
try {
|
|
321
|
+
impersonate = await import("@webcrawl/curl-impersonate");
|
|
322
|
+
}
|
|
323
|
+
catch {
|
|
324
|
+
return null;
|
|
325
|
+
}
|
|
326
|
+
// Same identity-header hygiene as the direct impersonate hop: the profile's
|
|
327
|
+
// UA/sec-ch headers must survive or the UA/TLS mismatch is what DataDome
|
|
328
|
+
// blocks even from a clean IP.
|
|
329
|
+
const extra = {};
|
|
330
|
+
for (const [key, value] of Object.entries(reqHeaders)) {
|
|
331
|
+
const lower = key.toLowerCase();
|
|
332
|
+
if (lower === "user-agent" ||
|
|
333
|
+
lower.startsWith("sec-ch-ua") ||
|
|
334
|
+
lower.startsWith("sec-fetch-") ||
|
|
335
|
+
lower === "upgrade-insecure-requests" ||
|
|
336
|
+
lower === "priority") {
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
extra[key] = value;
|
|
340
|
+
}
|
|
341
|
+
try {
|
|
342
|
+
const res = await impersonate.impersonateFetch(url, {
|
|
343
|
+
headers: extra,
|
|
344
|
+
timeoutMs,
|
|
345
|
+
maxBytes: maxBytes ?? 2 * 1024 * 1024,
|
|
346
|
+
proxy: proxyUrl,
|
|
347
|
+
});
|
|
348
|
+
lastProxyBytes += res.bytes;
|
|
349
|
+
const headers = res.headers;
|
|
350
|
+
const isRedirect = res.status >= 300 && res.status < 400 && !!headers["location"];
|
|
351
|
+
if (!isRedirect && maxBytes !== undefined)
|
|
352
|
+
checkDeclaredLength(headers, maxBytes);
|
|
353
|
+
return {
|
|
354
|
+
status: res.status,
|
|
355
|
+
headers,
|
|
356
|
+
body: isRedirect ? new ArrayBuffer(0) : res.body.buffer,
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
catch (err) {
|
|
360
|
+
if (err instanceof UnsafeUrlError)
|
|
361
|
+
throw err;
|
|
362
|
+
return null; // proxy tier unavailable or fetch failed — caller escalates
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
/**
|
|
366
|
+
* Fetches a URL using `impit` (BoringSSL Chrome 136 TLS JA3/JA4 + HTTP/2 fingerprint impersonation)sonation)
|
|
367
|
+
* so Cloudflare Bot Management and Vercel Security Checkpoint treat requests as real Chrome.
|
|
368
|
+
* Falls back to standard `fetch` if `impit` is unavailable on the current platform.
|
|
369
|
+
* Every redirect hop is checked against the SSRF guard.
|
|
370
|
+
*/
|
|
371
|
+
export async function fetchWithTlsImpersonation(url, timeoutMs = 12_000) {
|
|
372
|
+
// Session hydration: when a warm stealth session exists for this domain, its
|
|
373
|
+
// solved cookies (and the UA they were solved under — bound cookies require
|
|
374
|
+
// the pair) ride the request so the lightweight tier can skip the challenge.
|
|
375
|
+
let hostname = "";
|
|
376
|
+
try {
|
|
377
|
+
hostname = new URL(url).hostname;
|
|
378
|
+
}
|
|
379
|
+
catch {
|
|
380
|
+
/* invalid URL handled below */
|
|
381
|
+
}
|
|
382
|
+
if (hostname) {
|
|
383
|
+
try {
|
|
384
|
+
const { getWarmSession } = await import("./session.js");
|
|
385
|
+
const warm = await getWarmSession(hostname);
|
|
386
|
+
if (warm) {
|
|
387
|
+
if (warm.cookieHeader)
|
|
388
|
+
setDomainCookie(hostname, warm.cookieHeader);
|
|
389
|
+
if (warm.userAgent)
|
|
390
|
+
setDomainUserAgent(hostname, warm.userAgent);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
catch {
|
|
394
|
+
/* session module unavailable — plain fetch */
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
const { res, finalUrl } = await followSafely(url, (hop) => rawGet(hop, getBrowserHeaders(hop), timeoutMs));
|
|
398
|
+
if (res.headers["set-cookie"]) {
|
|
399
|
+
try {
|
|
400
|
+
setDomainCookie(new URL(finalUrl).hostname, res.headers["set-cookie"].split(";")[0]);
|
|
401
|
+
}
|
|
402
|
+
catch {
|
|
403
|
+
// ignore
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
return {
|
|
407
|
+
status: res.status,
|
|
408
|
+
html: new TextDecoder().decode(res.body),
|
|
409
|
+
headers: res.headers,
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
export class ResourceTooLargeError extends Error {
|
|
413
|
+
bytes;
|
|
414
|
+
maxBytes;
|
|
415
|
+
constructor(bytes, maxBytes) {
|
|
416
|
+
super(`File is ${(bytes / 1e6).toFixed(1)} MB; the limit is ${(maxBytes / 1e6).toFixed(0)} MB`);
|
|
417
|
+
this.bytes = bytes;
|
|
418
|
+
this.maxBytes = maxBytes;
|
|
419
|
+
this.name = "ResourceTooLargeError";
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
/** Largest download accepted for any file (the per-type caps in src/parse/limits.ts are tighter). */
|
|
423
|
+
export const MAX_RESOURCE_BYTES = 100 * 1024 * 1024;
|
|
424
|
+
function headersToObject(headers) {
|
|
425
|
+
const out = {};
|
|
426
|
+
const h = headers;
|
|
427
|
+
if (h && typeof h.forEach === "function") {
|
|
428
|
+
h.forEach((v, k) => {
|
|
429
|
+
out[String(k).toLowerCase()] = String(v);
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
return out;
|
|
433
|
+
}
|
|
434
|
+
function checkDeclaredLength(headers, maxBytes) {
|
|
435
|
+
const declared = Number(headers["content-length"]);
|
|
436
|
+
if (Number.isFinite(declared) && declared > maxBytes) {
|
|
437
|
+
throw new ResourceTooLargeError(declared, maxBytes);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
/**
|
|
441
|
+
* Binary-safe GET. Same TLS impersonation as `fetchWithTlsImpersonation`, but keeps
|
|
442
|
+
* the body as bytes so PDFs, Office files, images and media survive intact.
|
|
443
|
+
*/
|
|
444
|
+
export async function fetchResource(url, options = {}) {
|
|
445
|
+
const timeoutMs = options.timeoutMs ?? 30_000;
|
|
446
|
+
const maxBytes = options.maxBytes ?? MAX_RESOURCE_BYTES;
|
|
447
|
+
const { res, finalUrl } = await followSafely(url, (hop) => rawGet(hop, { ...getBrowserHeaders(hop), Accept: "*/*" }, timeoutMs, maxBytes));
|
|
448
|
+
if (res.body.byteLength > maxBytes)
|
|
449
|
+
throw new ResourceTooLargeError(res.body.byteLength, maxBytes);
|
|
450
|
+
return {
|
|
451
|
+
status: res.status,
|
|
452
|
+
headers: res.headers,
|
|
453
|
+
contentType: (res.headers["content-type"] || "").split(";")[0].trim().toLowerCase(),
|
|
454
|
+
body: new Uint8Array(res.body),
|
|
455
|
+
url: finalUrl,
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
/**
|
|
459
|
+
* Detects whether an HTML response is an empty SPA shell or a Cloudflare/WAF challenge page
|
|
460
|
+
* that requires headless browser / Vercel Sandbox escalation.
|
|
461
|
+
*/
|
|
462
|
+
export function needsBrowserEscalation(status, html, markdown) {
|
|
463
|
+
if (isAntiBotChallenge(html, status)) {
|
|
464
|
+
return true;
|
|
465
|
+
}
|
|
466
|
+
const text = markdown.trim();
|
|
467
|
+
const lower = html.slice(0, 8000).toLowerCase();
|
|
468
|
+
// Empty or near-empty body: nothing to return until a renderer hydrates it.
|
|
469
|
+
if (text.length === 0) {
|
|
470
|
+
return true;
|
|
471
|
+
}
|
|
472
|
+
// Empty SPA shell check (<div id="root"></div> or <div id="app"></div> with almost no text)
|
|
473
|
+
if (text.length < 200 &&
|
|
474
|
+
(lower.includes('id="root"') ||
|
|
475
|
+
lower.includes('id="app"') ||
|
|
476
|
+
lower.includes('id="__next"') ||
|
|
477
|
+
lower.includes('id="__nuxt"'))) {
|
|
478
|
+
return true;
|
|
479
|
+
}
|
|
480
|
+
// Header-only JS pages (quotes.toscrape.com/js): real content lives in a
|
|
481
|
+
// <script> as JSON + document.write / innerHTML, so markdown is just the chrome.
|
|
482
|
+
if (text.length < 200 &&
|
|
483
|
+
/<script[\s>]/i.test(html) &&
|
|
484
|
+
/document\.write|innerHTML\s*=/i.test(html)) {
|
|
485
|
+
return true;
|
|
486
|
+
}
|
|
487
|
+
// Big-HTML/no-content SPA shell: a very large page (JS bundles / inline
|
|
488
|
+
// state, e.g. StubHub category pages ~3MB) that yields almost no
|
|
489
|
+
// extractable text is serving its real content client-side.
|
|
490
|
+
if (html.length > 250_000 && text.length < 500) {
|
|
491
|
+
return true;
|
|
492
|
+
}
|
|
493
|
+
return false;
|
|
494
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export declare function decodeHtmlEntities(text: string): string;
|
|
2
|
+
export interface MarkdownOptions {
|
|
3
|
+
retainImages?: "all" | "none";
|
|
4
|
+
/** Default true: prefer <main>/<article> and drop header/footer/nav/aside. */
|
|
5
|
+
onlyMainContent?: boolean;
|
|
6
|
+
/** Simple selectors (`tag`, `.class`, `#id`, `tag.class`). Keep only matching elements. */
|
|
7
|
+
includeTags?: string[];
|
|
8
|
+
/** Same selector syntax. Matching elements are removed. */
|
|
9
|
+
excludeTags?: string[];
|
|
10
|
+
}
|
|
11
|
+
export declare function removeElements(html: string, rawSelectors: string[]): string;
|
|
12
|
+
export declare function keepElements(html: string, rawSelectors: string[]): string;
|
|
13
|
+
/**
|
|
14
|
+
* Ultra-fast zero-DOM streaming regex/state-machine HTML-to-Markdown converter.
|
|
15
|
+
* Processes a 200KB HTML document in ~1.2ms (vs 45-120ms for JSDOM + Turndown).
|
|
16
|
+
*/
|
|
17
|
+
export declare function fastHtmlToMarkdown(html: string, baseUrl: string, options?: MarkdownOptions): {
|
|
18
|
+
title: string;
|
|
19
|
+
markdown: string;
|
|
20
|
+
links: string[];
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* Fast anchor href extractor for high-speed multi-hop BFS crawling.
|
|
24
|
+
*/
|
|
25
|
+
export declare function extractLinksFromHtml(html: string, pageUrl: string): string[];
|
|
26
|
+
/**
|
|
27
|
+
* Discovers all internal URLs for a website:
|
|
28
|
+
* 1. Checks 24-hour cache first (unless `bypassCache: true`).
|
|
29
|
+
* 2. Probes `robots.txt`, `/sitemap.xml`, `/sitemap_index.xml`, and subpath `sitemap.xml` in parallel.
|
|
30
|
+
* 3. When `sitemap.xml` is missing (or incomplete), performs a full multi-hop concurrent BFS crawl
|
|
31
|
+
* across the entire website (with automatic `@vercel/sandbox` / browser escalation if the entry page is an SPA),
|
|
32
|
+
* and caches both the discovered site map and scraped pages for 24 hours.
|
|
33
|
+
*/
|