webcrawl 1.2.5 → 1.2.7
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 +37 -0
- package/dist/core/engine/awswaf.js +189 -0
- package/package.json +2 -2
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/** True when an HTML response (with optional status) is an AWS WAF challenge interstitial. */
|
|
2
|
+
export declare function isAwsWafChallenge(html: string, status?: number): boolean;
|
|
3
|
+
/** True when a fetch response is any known bot check (upstream detector + AWS WAF). */
|
|
4
|
+
export declare function isBlockedLike(html: string, status?: number): boolean;
|
|
5
|
+
export interface AwsWafPage {
|
|
6
|
+
url: string;
|
|
7
|
+
title: string;
|
|
8
|
+
markdown: string;
|
|
9
|
+
linksCount: number;
|
|
10
|
+
cached: false;
|
|
11
|
+
cacheLayer: "miss";
|
|
12
|
+
structuredData: null;
|
|
13
|
+
statusCode?: number;
|
|
14
|
+
blocked?: boolean;
|
|
15
|
+
error?: string;
|
|
16
|
+
awsWaf?: boolean;
|
|
17
|
+
stealth?: boolean;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Opens `url` in a real local Chromium, waits out the AWS WAF challenge
|
|
21
|
+
* (the interstitial reloads itself once `AwsWafIntegration.getToken()`
|
|
22
|
+
* resolves), then returns the rendered page as a CrawledPage-shaped result.
|
|
23
|
+
* Never fabricates content: a page still showing the challenge comes back
|
|
24
|
+
* `blocked: true` with an honest error.
|
|
25
|
+
*/
|
|
26
|
+
export declare function awsWafStealthFetch(url: string, opts?: {
|
|
27
|
+
timeoutMs?: number;
|
|
28
|
+
}): Promise<AwsWafPage>;
|
|
29
|
+
/**
|
|
30
|
+
* Reads one page: plain TLS-impersonated fetch first; when the site answers
|
|
31
|
+
* with an AWS WAF challenge (which the upstream detector misses), routes
|
|
32
|
+
* through the local stealth browser instead.
|
|
33
|
+
*/
|
|
34
|
+
export declare function crawlPageWithAwsWaf(url: string, opts?: {
|
|
35
|
+
bypassCache?: boolean;
|
|
36
|
+
stealthTimeoutMs?: number;
|
|
37
|
+
}): Promise<AwsWafPage>;
|
|
@@ -0,0 +1,189 @@
|
|
|
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
|
+
import { fetchWithTlsImpersonation, fastHtmlToMarkdown, isAntiBotChallenge } from "../engine.js";
|
|
19
|
+
/** Markers that only appear on an AWS WAF challenge interstitial. */
|
|
20
|
+
const AWS_WAF_MARKERS = [
|
|
21
|
+
"awswafcookiedomainlist",
|
|
22
|
+
"awswafintegration",
|
|
23
|
+
"aws-waf-token",
|
|
24
|
+
"id=\"challenge-container\"",
|
|
25
|
+
"/__challenge_",
|
|
26
|
+
];
|
|
27
|
+
/** True when an HTML response (with optional status) is an AWS WAF challenge interstitial. */
|
|
28
|
+
export function isAwsWafChallenge(html, status) {
|
|
29
|
+
const lower = String(html ?? "").slice(0, 8000).toLowerCase();
|
|
30
|
+
const hasMarker = AWS_WAF_MARKERS.some((m) => lower.includes(m));
|
|
31
|
+
if (hasMarker)
|
|
32
|
+
return true;
|
|
33
|
+
// 202 alone is the documented Challenge status, but APIs can legitimately
|
|
34
|
+
// return 202 - only treat it as a challenge when the body is empty/challenge-ish.
|
|
35
|
+
if (status === 202) {
|
|
36
|
+
// 202 is the documented Challenge interstitial status. The interstitial's
|
|
37
|
+
// entire payload is <script>/<noscript>, so visible text is empty.
|
|
38
|
+
if (!lower.includes("<html"))
|
|
39
|
+
return false;
|
|
40
|
+
const visibleText = lower
|
|
41
|
+
.slice(0, 8000)
|
|
42
|
+
.replace(/<script[\s\S]*?<\/script>/g, "")
|
|
43
|
+
.replace(/<noscript[\s\S]*?<\/noscript>/g, "")
|
|
44
|
+
.replace(/<style[\s\S]*?<\/style>/g, "")
|
|
45
|
+
.replace(/<[^>]+>/g, " ")
|
|
46
|
+
.trim();
|
|
47
|
+
return visibleText.length === 0;
|
|
48
|
+
}
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
/** True when a fetch response is any known bot check (upstream detector + AWS WAF). */
|
|
52
|
+
export function isBlockedLike(html, status) {
|
|
53
|
+
return isAntiBotChallenge(html ?? "", status ?? 200) || isAwsWafChallenge(html ?? "", status);
|
|
54
|
+
}
|
|
55
|
+
const MAC_CHROME = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome";
|
|
56
|
+
const CHALLENGE_TIMEOUT_MS = 45_000;
|
|
57
|
+
/** Waits for a dynamically-installed playwright-core (avoids a hard dep). */
|
|
58
|
+
async function loadPlaywrightCore() {
|
|
59
|
+
// Non-literal specifier: playwright-core stays a soft, runtime-only dependency
|
|
60
|
+
// so `tsc --noEmit` passes without the package installed.
|
|
61
|
+
const specifier = "playwright-core";
|
|
62
|
+
return (await import(specifier));
|
|
63
|
+
}
|
|
64
|
+
function chromePath() {
|
|
65
|
+
return process.env.INTERACT_CHROME_PATH || MAC_CHROME;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Opens `url` in a real local Chromium, waits out the AWS WAF challenge
|
|
69
|
+
* (the interstitial reloads itself once `AwsWafIntegration.getToken()`
|
|
70
|
+
* resolves), then returns the rendered page as a CrawledPage-shaped result.
|
|
71
|
+
* Never fabricates content: a page still showing the challenge comes back
|
|
72
|
+
* `blocked: true` with an honest error.
|
|
73
|
+
*/
|
|
74
|
+
export async function awsWafStealthFetch(url, opts = {}) {
|
|
75
|
+
const fail = (error, blocked = true) => ({
|
|
76
|
+
url,
|
|
77
|
+
title: url,
|
|
78
|
+
markdown: "",
|
|
79
|
+
linksCount: 0,
|
|
80
|
+
cached: false,
|
|
81
|
+
cacheLayer: "miss",
|
|
82
|
+
structuredData: null,
|
|
83
|
+
blocked,
|
|
84
|
+
error,
|
|
85
|
+
awsWaf: true,
|
|
86
|
+
stealth: true,
|
|
87
|
+
});
|
|
88
|
+
const { chromium } = await loadPlaywrightCore();
|
|
89
|
+
const executablePath = chromePath();
|
|
90
|
+
const browser = await chromium.launch({
|
|
91
|
+
executablePath,
|
|
92
|
+
headless: true,
|
|
93
|
+
args: [
|
|
94
|
+
"--no-sandbox",
|
|
95
|
+
"--disable-gpu",
|
|
96
|
+
"--disable-dev-shm-usage",
|
|
97
|
+
"--no-first-run",
|
|
98
|
+
"--no-default-browser-check",
|
|
99
|
+
// Hide navigator.webdriver, which bot checks look for.
|
|
100
|
+
"--disable-blink-features=AutomationControlled",
|
|
101
|
+
`--window-size=1280,800`,
|
|
102
|
+
],
|
|
103
|
+
});
|
|
104
|
+
try {
|
|
105
|
+
const ctx = await browser.newContext({
|
|
106
|
+
userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
|
|
107
|
+
viewport: { width: 1280, height: 800 },
|
|
108
|
+
locale: "en-US",
|
|
109
|
+
});
|
|
110
|
+
await ctx.addInitScript("Object.defineProperty(Navigator.prototype,'webdriver',{get:()=>false});");
|
|
111
|
+
const page = await ctx.newPage();
|
|
112
|
+
let navStatus = null;
|
|
113
|
+
page.on("response", (r) => {
|
|
114
|
+
try {
|
|
115
|
+
if (r.request().isNavigationRequest() && r.frame() === page.mainFrame())
|
|
116
|
+
navStatus = r.status();
|
|
117
|
+
}
|
|
118
|
+
catch { }
|
|
119
|
+
});
|
|
120
|
+
// The interstitial reloads itself after getToken(); domcontentloaded +
|
|
121
|
+
// marker polling survives that reload race.
|
|
122
|
+
await page.goto(url, { waitUntil: "domcontentloaded", timeout: Math.min(30_000, opts.timeoutMs ?? 30_000) });
|
|
123
|
+
const deadline = Date.now() + (opts.timeoutMs ?? CHALLENGE_TIMEOUT_MS);
|
|
124
|
+
let html = "";
|
|
125
|
+
for (;;) {
|
|
126
|
+
html = await page.content().catch(() => "");
|
|
127
|
+
if (!isAwsWafChallenge(html) && !isAntiBotChallenge(html, 200))
|
|
128
|
+
break;
|
|
129
|
+
if (Date.now() > deadline)
|
|
130
|
+
break;
|
|
131
|
+
await page.waitForTimeout(700).catch(() => { });
|
|
132
|
+
}
|
|
133
|
+
try {
|
|
134
|
+
await page.waitForLoadState("networkidle", { timeout: 2500 });
|
|
135
|
+
}
|
|
136
|
+
catch { }
|
|
137
|
+
html = await page.content().catch(() => html);
|
|
138
|
+
if (isAwsWafChallenge(html) || isAntiBotChallenge(html, 200)) {
|
|
139
|
+
return { ...fail("Still blocked by the site's AWS WAF challenge in local stealth mode"), statusCode: navStatus ?? undefined };
|
|
140
|
+
}
|
|
141
|
+
const finalUrl = page.url();
|
|
142
|
+
const parsed = fastHtmlToMarkdown(html, finalUrl || url, { retainImages: "all" });
|
|
143
|
+
if (parsed.markdown.length === 0) {
|
|
144
|
+
return {
|
|
145
|
+
...fail(`Empty page after challenge (HTTP ${navStatus ?? "?"})`, false),
|
|
146
|
+
statusCode: navStatus ?? undefined,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
return {
|
|
150
|
+
url,
|
|
151
|
+
title: parsed.title || finalUrl || url,
|
|
152
|
+
markdown: parsed.markdown,
|
|
153
|
+
linksCount: parsed.links.length,
|
|
154
|
+
cached: false,
|
|
155
|
+
cacheLayer: "miss",
|
|
156
|
+
structuredData: null,
|
|
157
|
+
statusCode: navStatus ?? 200,
|
|
158
|
+
awsWaf: true,
|
|
159
|
+
stealth: true,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
finally {
|
|
163
|
+
await browser.close().catch(() => { });
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Reads one page: plain TLS-impersonated fetch first; when the site answers
|
|
168
|
+
* with an AWS WAF challenge (which the upstream detector misses), routes
|
|
169
|
+
* through the local stealth browser instead.
|
|
170
|
+
*/
|
|
171
|
+
export async function crawlPageWithAwsWaf(url, opts = {}) {
|
|
172
|
+
const fetched = await fetchWithTlsImpersonation(url, 12_000);
|
|
173
|
+
if (!isAwsWafChallenge(fetched.html, fetched.status)) {
|
|
174
|
+
const parsed = fastHtmlToMarkdown(fetched.html, url, { retainImages: "all" });
|
|
175
|
+
return {
|
|
176
|
+
url,
|
|
177
|
+
title: parsed.title || url,
|
|
178
|
+
markdown: parsed.markdown,
|
|
179
|
+
linksCount: parsed.links.length,
|
|
180
|
+
cached: false,
|
|
181
|
+
cacheLayer: "miss",
|
|
182
|
+
structuredData: null,
|
|
183
|
+
statusCode: fetched.status,
|
|
184
|
+
...(parsed.markdown.length === 0 ? { error: `Empty page (HTTP ${fetched.status})` } : {}),
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
console.info("[awswaf] challenge detected, routing through local stealth browser", { url, status: fetched.status });
|
|
188
|
+
return awsWafStealthFetch(url, { timeoutMs: opts.stealthTimeoutMs });
|
|
189
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "webcrawl",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.7",
|
|
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.7",
|
|
131
131
|
"ai": "^7.0.150",
|
|
132
132
|
"impit": "^0.14.7",
|
|
133
133
|
"mammoth": "^1.13.0",
|