crawlforge-mcp-server 6.10.0 → 6.12.0
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/CLAUDE.md +5 -0
- package/package.json +4 -3
- package/server.js +32 -6
- package/src/core/StealthBrowserManager.js +107 -1
- package/src/schemas/toolOutputSchemas.js +1 -1
- package/src/skills/agent-skills/crawlforge-web-scraping/SKILL.md +8 -2
- package/src/tools/scrape/escalation.js +4 -2
- package/src/tools/scrape/unifiedScrape.js +2 -2
- package/src/utils/challengeDetection.js +22 -1
- package/src/utils/complianceAudit.js +20 -0
- package/src/utils/impitRung.js +157 -0
- package/src/utils/robotsGate.js +8 -2
package/CLAUDE.md
CHANGED
|
@@ -236,6 +236,11 @@ CRAWLFORGE_STEALTH_PROXIES= # comma-separated proxy URLs used by the es
|
|
|
236
236
|
# from the PROXY_ROTATION_* family, which belongs to
|
|
237
237
|
# localization. The agent's automatic stealth retry
|
|
238
238
|
# (stealth review Phase 3) reads it too.
|
|
239
|
+
CRAWLFORGE_IMPIT= # `off` skips escalation's impit step (a Chrome TLS handshake with
|
|
240
|
+
# the honest UA, tried before the browser; stealth review Phase 7).
|
|
241
|
+
# Exit-IP dependent like the engine: it cleared indeed.com from a
|
|
242
|
+
# residential IP and nothing from the hosted datacenter IP, so
|
|
243
|
+
# render.yaml sets it off there. Measure with scripts/impit-probe.mjs.
|
|
239
244
|
CRAWLFORGE_CLEARANCE_JAR= # `off` disables the clearance jar (stealth review Phase 4):
|
|
240
245
|
# cf_clearance/__cf_bm/datadome cookies a stealth render earned
|
|
241
246
|
# are replayed to the next stealth context with the same engine,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "crawlforge-mcp-server",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.12.0",
|
|
4
4
|
"mcpName": "io.github.mysleekdesigns/crawlforge-mcp-server",
|
|
5
5
|
"description": "CrawlForge MCP Server - Professional Model Context Protocol server with 31 web scraping, crawling, deep-research, and autonomous-extraction tools. Returns clean Markdown and structured JSON for Claude, Cursor, and any MCP client. Defaults to local Ollama for LLM extraction (no API key needed); OpenAI/Anthropic available as opt-in. Includes a unified multi-format scrape tool, an autonomous agent, pre-built site templates, and Camoufox stealth browsing.",
|
|
6
6
|
"main": "server.js",
|
|
@@ -121,7 +121,7 @@
|
|
|
121
121
|
"cheerio": "^1.1.2",
|
|
122
122
|
"commander": "^14.0.3",
|
|
123
123
|
"compromise": "^14.14.4",
|
|
124
|
-
"crawlforge-extractors": "^1.
|
|
124
|
+
"crawlforge-extractors": "^1.10.0",
|
|
125
125
|
"diff": "^9.0.0",
|
|
126
126
|
"dotenv": "^17.2.1",
|
|
127
127
|
"franc": "^6.2.0",
|
|
@@ -139,7 +139,8 @@
|
|
|
139
139
|
"zod": "^4.5.4"
|
|
140
140
|
},
|
|
141
141
|
"optionalDependencies": {
|
|
142
|
-
"camoufox": "^0.1.19"
|
|
142
|
+
"camoufox": "^0.1.19",
|
|
143
|
+
"impit": "^0.14.5"
|
|
143
144
|
},
|
|
144
145
|
"devDependencies": {
|
|
145
146
|
"@jest/globals": "^30.3.0",
|
package/server.js
CHANGED
|
@@ -59,7 +59,9 @@ import { READ_RESULT_INPUT_SHAPE, readResultHandler } from "./src/tools/result/r
|
|
|
59
59
|
import { MAX_INLINE_CHARS_PARAM } from "./src/server/inlineThreshold.js"; // Phase 2
|
|
60
60
|
import { REDACT_PII_PARAM } from "./src/server/redaction.js"; // Phase 5 (5.3)
|
|
61
61
|
import { SEARCH_QUERIES_PARAM, EXACTLY_ONE_QUERY_MESSAGE } from "./src/tools/search/batchSearch.js"; // Phase 5 (5.1)
|
|
62
|
-
import { markPreflightRefusal } from "./src/server/requestContext.js";
|
|
62
|
+
import { markPreflightRefusal, internalOwnerToken } from "./src/server/requestContext.js";
|
|
63
|
+
import { recordStealthEscalation } from "./src/utils/complianceAudit.js";
|
|
64
|
+
import { loadImpit, impitEnabled, impitFetchPage, IMPIT_ENGINE } from "./src/utils/impitRung.js"; // stealth review Phase 7
|
|
63
65
|
// D1.1 Resources + D1.2 Prompts + D1.4 Elicitation
|
|
64
66
|
import { ResourceRegistry, MAX_RESOURCE_BLOB_BYTES } from "./src/resources/ResourceRegistry.js";
|
|
65
67
|
import { PROMPTS, getPromptMessages } from "./src/prompts/PromptRegistry.js";
|
|
@@ -108,7 +110,7 @@ if (configErrors.length > 0 && config.server.nodeEnv === 'production') {
|
|
|
108
110
|
// Create the server
|
|
109
111
|
const server = new McpServer({
|
|
110
112
|
name: "crawlforge",
|
|
111
|
-
version: "6.
|
|
113
|
+
version: "6.12.0",
|
|
112
114
|
description: "Production-ready MCP server with 31 web scraping, crawling, and content processing tools. Features MCP Resources (crawlforge://), Prompts, Sampling fallback, Elicitation, stealth browsing, stateful browser sessions with element refs, deep research, structured extraction, embedded JavaScript state extraction, real Google SERP rank tracking, Reddit search via community archives, change tracking, local-LLM extraction via Ollama, unified multi-format scrape, and autonomous agent tool.",
|
|
113
115
|
homepage: "https://www.crawlforge.dev",
|
|
114
116
|
icon: "https://www.crawlforge.dev/icon.png",
|
|
@@ -248,17 +250,30 @@ const scrapeTemplateTool = new ScrapeTemplateTool(); // D3.3
|
|
|
248
250
|
// go through the same gate, engine resolver and server-level proxy list.
|
|
249
251
|
// Injected so the tool modules never import StealthBrowserManager (that would pull a
|
|
250
252
|
// browser dependency into every unit test that loads `scrape`). Same gate
|
|
251
|
-
// and same browser the stealth_mode tool drives
|
|
253
|
+
// and same browser the stealth_mode tool drives, plus the impit TLS try, and the
|
|
252
254
|
// engine name is resolved here, beside its sibling, by the one resolver
|
|
253
255
|
// every stealth entry point shares.
|
|
254
|
-
const stealthEscalation = async ({ url, engine, respectRobots }) => {
|
|
256
|
+
const stealthEscalation = async ({ url, engine, respectRobots, tool }) => {
|
|
255
257
|
const warnings = await stealthComplianceGate(url, respectRobots);
|
|
258
|
+
const auditIdentity = { apiKey: AuthManager.getConfig()?.apiKey, ownerToken: internalOwnerToken() };
|
|
259
|
+
// Stealth review Phase 7: under "auto", a Chrome TLS handshake with the
|
|
260
|
+
// honest User-Agent is tried before any browser launches. A caller who
|
|
261
|
+
// named an engine asked for that browser, so it is not tried for them.
|
|
262
|
+
// CRAWLFORGE_IMPIT=off skips it for a deployment whose exit IP it cannot help.
|
|
263
|
+
if (engine === 'auto' && impitEnabled() && await loadImpit()) {
|
|
264
|
+
recordStealthEscalation({ url, tool, engine: IMPIT_ENGINE, ...auditIdentity });
|
|
265
|
+
const page = await impitFetchPage(url);
|
|
266
|
+
if (page) return { ...page, warnings };
|
|
267
|
+
}
|
|
256
268
|
// "auto" prefers camoufox and falls back to chromium when its binary is
|
|
257
269
|
// missing. Mapping here instead ("camoufox" or else chromium) collapsed
|
|
258
270
|
// auto to chromium and hid the fallback; the resolver says which engine it
|
|
259
271
|
// landed on and why, and the caller is told.
|
|
260
272
|
const resolved = await resolveStealthEngine(engine);
|
|
261
273
|
if (resolved.fallbackWarning) warnings.push(resolved.fallbackWarning);
|
|
274
|
+
// Audit row: the request is about to go out under a browser identity. After
|
|
275
|
+
// the gate (a refusal presents none) and the resolver (the engine that runs).
|
|
276
|
+
recordStealthEscalation({ url, tool, engine: resolved.engine, ...auditIdentity });
|
|
262
277
|
const scraped = await stealthBrowserManager.scrapeWithStealth({
|
|
263
278
|
url,
|
|
264
279
|
engine: resolved.engine
|
|
@@ -269,9 +284,9 @@ const stealthEscalation = async ({ url, engine, respectRobots }) => {
|
|
|
269
284
|
};
|
|
270
285
|
const unifiedScrapeTool = new UnifiedScrapeTool({
|
|
271
286
|
actionExecutor: scrapeWithActionsTool.actionExecutor, // D4 D1 (+v4.8 screenshot reuses the shared browser pool)
|
|
272
|
-
escalateScrape: stealthEscalation
|
|
287
|
+
escalateScrape: (args) => stealthEscalation({ ...args, tool: 'scrape' })
|
|
273
288
|
});
|
|
274
|
-
const agentTool = new AgentTool({ escalateFetch: stealthEscalation }); // D4 D2 + stealth review Phase 3
|
|
289
|
+
const agentTool = new AgentTool({ escalateFetch: (args) => stealthEscalation({ ...args, tool: 'agent' }) }); // D4 D2 + stealth review Phase 3
|
|
275
290
|
const stealthBrowserManager = new StealthBrowserManager();
|
|
276
291
|
const localizationManager = new LocalizationManager();
|
|
277
292
|
|
|
@@ -1419,6 +1434,11 @@ registerToolIfEnabled("stealth_mode", {
|
|
|
1419
1434
|
const resolvedEngine = await resolveStealthEngine(engine);
|
|
1420
1435
|
if (resolvedEngine.fallbackWarning) warnings.push(resolvedEngine.fallbackWarning);
|
|
1421
1436
|
|
|
1437
|
+
recordStealthEscalation({
|
|
1438
|
+
url, tool: 'stealth_mode', engine: resolvedEngine.engine,
|
|
1439
|
+
apiKey: AuthManager.getConfig()?.apiKey, ownerToken: internalOwnerToken()
|
|
1440
|
+
});
|
|
1441
|
+
|
|
1422
1442
|
const wantsScreenshot = formats.includes('screenshot');
|
|
1423
1443
|
const scraped = await stealthBrowserManager.scrapeWithStealth({
|
|
1424
1444
|
url,
|
|
@@ -1507,6 +1527,11 @@ registerToolIfEnabled("stealth_mode", {
|
|
|
1507
1527
|
let navigation = null;
|
|
1508
1528
|
try {
|
|
1509
1529
|
if (urlToTest) {
|
|
1530
|
+
recordStealthEscalation({
|
|
1531
|
+
url: urlToTest, tool: 'stealth_mode',
|
|
1532
|
+
engine: stealthBrowserManager.contexts.get(contextId)?.config?.engine ?? null,
|
|
1533
|
+
apiKey: AuthManager.getConfig()?.apiKey, ownerToken: internalOwnerToken()
|
|
1534
|
+
});
|
|
1510
1535
|
// page.goto returns a Playwright Response handle, which is not
|
|
1511
1536
|
// JSON-serializable — extract just the useful navigation details.
|
|
1512
1537
|
// Explicit timeout keeps navigation inside every caller's window
|
|
@@ -1520,6 +1545,7 @@ registerToolIfEnabled("stealth_mode", {
|
|
|
1520
1545
|
title: await page.title().catch(() => null)
|
|
1521
1546
|
};
|
|
1522
1547
|
const challenge = detectChallengePage({
|
|
1548
|
+
status: navigation.status,
|
|
1523
1549
|
title: navigation.title || '',
|
|
1524
1550
|
html: await page.content().catch(() => ''),
|
|
1525
1551
|
text: await page.innerText('body').catch(() => '')
|
|
@@ -26,6 +26,47 @@ import { ClearanceJar, sharedClearanceJar } from './ClearanceJar.js';
|
|
|
26
26
|
// Grace given to a document that rendered no title and no text (see _waitOutEmptyDocument).
|
|
27
27
|
export const EMPTY_DOCUMENT_GRACE_MS = 8000;
|
|
28
28
|
|
|
29
|
+
// Where the Turnstile checkbox sits inside its frame: 28 px in from the left
|
|
30
|
+
// edge (16 px padding + half the 24 px box) of the 300×65 normal-size widget,
|
|
31
|
+
// vertically centred. A fixed offset into the frame's box, because the
|
|
32
|
+
// checkbox itself lives in a closed shadow root inside a cross-origin frame
|
|
33
|
+
// that vanilla Playwright cannot select into — but the frame element's box it
|
|
34
|
+
// can read, from page.frames(), whatever shadow root the frame is mounted in.
|
|
35
|
+
export const TURNSTILE_CHECKBOX_OFFSET_X = 28;
|
|
36
|
+
const TURNSTILE_FRAME_URL = /^https:\/\/challenges\.cloudflare\.com\//;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The first Turnstile frame box whose checkbox point is on screen. A page can
|
|
40
|
+
* mount a widget off screen (nowsecure.nl mounts one at y=-147), and a click
|
|
41
|
+
* there lands on nothing.
|
|
42
|
+
* @param {Array<{x:number,y:number,width:number,height:number}|null>} boxes
|
|
43
|
+
* @param {{width:number,height:number}|null} viewport
|
|
44
|
+
* @returns {{x:number,y:number,width:number,height:number}|null}
|
|
45
|
+
*/
|
|
46
|
+
export function pickTurnstileBox(boxes, viewport) {
|
|
47
|
+
for (const box of boxes) {
|
|
48
|
+
if (!box || !(box.width > 0) || !(box.height > 0)) continue;
|
|
49
|
+
const { x, y } = turnstileClickPoint(box);
|
|
50
|
+
if (x < 0 || y < 0) continue;
|
|
51
|
+
if (viewport && (x > viewport.width || y > viewport.height)) continue;
|
|
52
|
+
return box;
|
|
53
|
+
}
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The checkbox point for a frame box, clamped inside the box so a compact
|
|
59
|
+
* widget narrower than the offset is still clicked inside itself.
|
|
60
|
+
* @param {{x:number,y:number,width:number,height:number}} box
|
|
61
|
+
* @returns {{x:number,y:number}}
|
|
62
|
+
*/
|
|
63
|
+
export function turnstileClickPoint(box) {
|
|
64
|
+
return {
|
|
65
|
+
x: box.x + Math.min(TURNSTILE_CHECKBOX_OFFSET_X, box.width / 2),
|
|
66
|
+
y: box.y + box.height / 2
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
29
70
|
// The Chrome major claimed when the installed binary cannot be read at all.
|
|
30
71
|
// Only ever a floor: installedChromeVersion() prefers playwright-core's own
|
|
31
72
|
// browsers.json, and a launched browser's real version overrides both.
|
|
@@ -2740,6 +2781,69 @@ export class StealthBrowserManager {
|
|
|
2740
2781
|
await page.waitForLoadState('domcontentloaded', { timeout: 5000 }).catch(() => {});
|
|
2741
2782
|
}
|
|
2742
2783
|
|
|
2784
|
+
/**
|
|
2785
|
+
* Click the Turnstile checkbox of a challenge that did not solve itself
|
|
2786
|
+
* (review Phase 5). A fixed offset into the challenges.cloudflare.com
|
|
2787
|
+
* frame's box, through page.mouse — plain Playwright, no shadow-root access.
|
|
2788
|
+
* Then wait for either the widget's token or the title to change, and give a
|
|
2789
|
+
* wall that is now navigating on its usual wait-out.
|
|
2790
|
+
*
|
|
2791
|
+
* Verified against Cloudflare's forced-interactive test sitekey on a local
|
|
2792
|
+
* page, which proves the mechanism only: whether a real site's challenge
|
|
2793
|
+
* accepts the click still depends on the IP and fingerprint it scores.
|
|
2794
|
+
*
|
|
2795
|
+
* @returns {Promise<boolean>} whether a click was made
|
|
2796
|
+
*/
|
|
2797
|
+
async _clickTurnstile(page, { timeoutMs = 10000 } = {}) {
|
|
2798
|
+
const frames = page.frames().filter((frame) => TURNSTILE_FRAME_URL.test(frame.url()));
|
|
2799
|
+
if (frames.length === 0) return false;
|
|
2800
|
+
const boxes = [];
|
|
2801
|
+
for (const frame of frames) {
|
|
2802
|
+
const element = await frame.frameElement().catch(() => null);
|
|
2803
|
+
boxes.push(element ? await element.boundingBox().catch(() => null) : null);
|
|
2804
|
+
}
|
|
2805
|
+
const box = pickTurnstileBox(boxes, page.viewportSize());
|
|
2806
|
+
if (!box) return false;
|
|
2807
|
+
const { x, y } = turnstileClickPoint(box);
|
|
2808
|
+
const title = await page.title().catch(() => '');
|
|
2809
|
+
await page.mouse.move(x - 40, y - 12);
|
|
2810
|
+
await page.mouse.move(x, y, { steps: 8 });
|
|
2811
|
+
await page.mouse.click(x, y, { delay: 90 });
|
|
2812
|
+
await page
|
|
2813
|
+
.waitForFunction(
|
|
2814
|
+
(t) => document.title !== t ||
|
|
2815
|
+
[...document.querySelectorAll('input[name="cf-turnstile-response"]')].some((input) => input.value),
|
|
2816
|
+
title,
|
|
2817
|
+
{ timeout: timeoutMs }
|
|
2818
|
+
)
|
|
2819
|
+
.catch(() => {});
|
|
2820
|
+
await this._waitOutChallenge(page);
|
|
2821
|
+
return true;
|
|
2822
|
+
}
|
|
2823
|
+
|
|
2824
|
+
/**
|
|
2825
|
+
* The click, only where it belongs: on Chromium, and only on a document the
|
|
2826
|
+
* verdict still calls a Cloudflare wall. A real page that embeds a widget
|
|
2827
|
+
* (quora.com's login, nowsecure.nl at 200) is never clicked.
|
|
2828
|
+
* @returns {Promise<boolean>} whether a click was made
|
|
2829
|
+
*/
|
|
2830
|
+
async _clickThroughChallenge(page, { status = null, engine = null } = {}) {
|
|
2831
|
+
if (engine !== 'chromium') return false;
|
|
2832
|
+
if (!page.frames().some((frame) => TURNSTILE_FRAME_URL.test(frame.url()))) return false;
|
|
2833
|
+
let title; let html; let text;
|
|
2834
|
+
try {
|
|
2835
|
+
[title, html, text] = await Promise.all([
|
|
2836
|
+
page.title(),
|
|
2837
|
+
page.content(),
|
|
2838
|
+
page.evaluate(() => (document.body ? document.body.innerText : ''))
|
|
2839
|
+
]);
|
|
2840
|
+
} catch {
|
|
2841
|
+
return false;
|
|
2842
|
+
}
|
|
2843
|
+
if (detectChallengePage({ title, html, text, status })?.vendor !== 'cloudflare') return false;
|
|
2844
|
+
return this._clickTurnstile(page);
|
|
2845
|
+
}
|
|
2846
|
+
|
|
2743
2847
|
async scrapeWithStealth({ url, engine, wait_for = 0, screenshot = false, stealthConfig = {} } = {}) {
|
|
2744
2848
|
if (!url) throw new Error('scrapeWithStealth requires a url');
|
|
2745
2849
|
|
|
@@ -2775,6 +2879,8 @@ export class StealthBrowserManager {
|
|
|
2775
2879
|
// up, so the interstitial came back as success:true (R17, 2026-09-04).
|
|
2776
2880
|
// An auto-solving challenge gets one bounded wait to finish first.
|
|
2777
2881
|
await this._waitOutChallenge(page);
|
|
2882
|
+
// A Turnstile challenge still up after that wait needs its checkbox clicked.
|
|
2883
|
+
await this._clickThroughChallenge(page, { status, engine: engineUsed });
|
|
2778
2884
|
const emptyGraceMs = await this._waitOutEmptyDocument(page);
|
|
2779
2885
|
// Last: let whatever is still rendering finish. Without this the read
|
|
2780
2886
|
// below can land between "the page has content" and "the page has the
|
|
@@ -2811,7 +2917,7 @@ export class StealthBrowserManager {
|
|
|
2811
2917
|
// Still a wall after everything above: whatever clearance this context
|
|
2812
2918
|
// carried for the site did not work, so closeContext drops it instead of
|
|
2813
2919
|
// keeping it for the next call.
|
|
2814
|
-
if (detectChallengePage({ title, html, text })) {
|
|
2920
|
+
if (detectChallengePage({ title, html, text, status })) {
|
|
2815
2921
|
this._markBlocked(contextId, [url, page.url()]);
|
|
2816
2922
|
}
|
|
2817
2923
|
|
|
@@ -138,7 +138,7 @@ const scrapeShape = {
|
|
|
138
138
|
stealth: z.object({
|
|
139
139
|
engine: z.string().optional(),
|
|
140
140
|
vendor_detected: z.string().nullable().optional()
|
|
141
|
-
}).passthrough().optional().describe('Present when escalated is true: the stealth engine that ran, and the bot-defence vendor the plain fetch hit (null when the block named none)'),
|
|
141
|
+
}).passthrough().optional().describe('Present when escalated is true: the stealth engine that ran ("impit" when the Chrome TLS handshake got the page without a browser), and the bot-defence vendor the plain fetch hit (null when the block named none)'),
|
|
142
142
|
content: z.object(scrapeFormatShapes).passthrough().optional().describe('One key per requested format'),
|
|
143
143
|
warnings: z.array(z.string()).optional().describe('Per-format warnings; partial success never fails the whole call'),
|
|
144
144
|
redaction: redactionShape,
|
|
@@ -80,11 +80,17 @@ shell). The escalated page goes through the same formats, so `markdown`,
|
|
|
80
80
|
```
|
|
81
81
|
|
|
82
82
|
The result adds `escalated: true|false`, and `stealth: { engine,
|
|
83
|
-
vendor_detected }` when the
|
|
83
|
+
vendor_detected }` when the escalation ran. `engine` is `impit` when a Chrome
|
|
84
|
+
TLS handshake with the honest User-Agent got the page before any browser
|
|
85
|
+
launched (default `escalate_engine: "auto"` only). The projection is `2 + 5`,
|
|
86
|
+
whichever step got the page; the charge
|
|
84
87
|
falls back to the base when the plain fetch succeeded and nothing escalated.
|
|
85
88
|
robots.txt is respected on the escalated path too, and a second call to a host
|
|
86
89
|
that blocked within the last 24 hours skips the doomed plain fetch and says so
|
|
87
|
-
in `warnings[]`.
|
|
90
|
+
in `warnings[]`. That call is charged the full `2 + 5` once the browser runs,
|
|
91
|
+
because no plain fetch ran that could have lowered it. The memory is kept for 24
|
|
92
|
+
hours from the block, in the running server only, and a `scrape` without
|
|
93
|
+
`escalate` that gets a clean page from that host clears it sooner.
|
|
88
94
|
|
|
89
95
|
`escalate_engine` defaults to `"auto"`: Camoufox (Firefox, the engine that gets
|
|
90
96
|
through Cloudflare Turnstile and Akamai) when its binary is installed, Chromium
|
|
@@ -5,7 +5,9 @@
|
|
|
5
5
|
* plain fetch comes back walled (Phase 3). The plain fetch always runs first
|
|
6
6
|
* (G1: never start with stealth), so the escalation is a second stage, not a
|
|
7
7
|
* different tool: it reuses the same compliance gate and the same
|
|
8
|
-
* StealthBrowserManager `stealth_mode` uses
|
|
8
|
+
* StealthBrowserManager `stealth_mode` uses. Its one addition (stealth review
|
|
9
|
+
* Phase 7) is a Chrome TLS handshake tried before the browser under "auto";
|
|
10
|
+
* see src/utils/impitRung.js.
|
|
9
11
|
*
|
|
10
12
|
* The two fields and the price rule live here so `server.js`, the tool
|
|
11
13
|
* module, `AuthManager.getToolCost` and the output schema read one
|
|
@@ -20,7 +22,7 @@ export const SCRAPE_ESCALATION_CREDITS = 5;
|
|
|
20
22
|
|
|
21
23
|
/** The two public fields, in tools/list order, with the text the client sees. */
|
|
22
24
|
export const SCRAPE_ESCALATION_SHAPE = {
|
|
23
|
-
escalate: z.boolean().optional().default(false).describe('When the plain fetch comes back blocked (403/429/challenge page/empty shell), retry once in the stealth browser and return its content instead of the block. Projected at 2+5; the actual charge stays at the base price when the plain fetch succeeded. Default: false'),
|
|
25
|
+
escalate: z.boolean().optional().default(false).describe('When the plain fetch comes back blocked (403/429/challenge page/empty shell), retry once in the stealth browser and return its content instead of the block. Under escalate_engine "auto" a Chrome TLS handshake with the honest CrawlForge User-Agent (impit) is tried first, and the browser runs only when that does not get the page. Projected at 2+5; the actual charge stays at the base price when the plain fetch succeeded. Default: false'),
|
|
24
26
|
// "auto" is the default because the engine that gets past the walls this
|
|
25
27
|
// stage exists for is camoufox, and asking for it by name used to be the
|
|
26
28
|
// caller's problem. It resolves to camoufox when the package is installed
|
|
@@ -380,7 +380,7 @@ export class UnifiedScrapeTool {
|
|
|
380
380
|
// Opt-in, and second by construction: the plain fetch above has already
|
|
381
381
|
// run and only a blocked verdict gets here (G1 — never start with
|
|
382
382
|
// stealth). The stage reuses the compliance gate and the browser
|
|
383
|
-
// `stealth_mode` already drives,
|
|
383
|
+
// `stealth_mode` already drives, plus the impit TLS try (Phase 7) before it.
|
|
384
384
|
// The vendor the plain fetch hit, or the one this host is remembered for
|
|
385
385
|
// when that fetch was skipped.
|
|
386
386
|
const vendorDetected = remembered ? (remembered.vendor ?? null) : (verdict?.blocked?.vendor ?? null);
|
|
@@ -428,7 +428,7 @@ export class UnifiedScrapeTool {
|
|
|
428
428
|
// browser fixes — so the wording must not call every one a block.
|
|
429
429
|
warnings.push(
|
|
430
430
|
`escalate: the plain fetch ${vendorDetected ? `was blocked by ${vendorDetected}` : 'did not return the page'}; ` +
|
|
431
|
-
`the ${stealthEngine} stealth browser ${verdict.success ? 'returned it' : 'did not get it either'}`
|
|
431
|
+
`${stealthEngine === 'impit' ? 'a Chrome TLS handshake (impit, honest User-Agent)' : `the ${stealthEngine} stealth browser`} ${verdict.success ? 'returned it' : 'did not get it either'}`
|
|
432
432
|
);
|
|
433
433
|
// The host memory is deliberately left alone here. It records what
|
|
434
434
|
// a PLAIN fetch met, which is what 3.3 reads before deciding to
|
|
@@ -76,7 +76,27 @@ function looksLikeRealPage({ title = '', text = '' }) {
|
|
|
76
76
|
}
|
|
77
77
|
|
|
78
78
|
/**
|
|
79
|
-
*
|
|
79
|
+
* A page the server answered 200 that carries only a Turnstile widget: no
|
|
80
|
+
* bootstrap (the caller has already ruled that out) and none of the
|
|
81
|
+
* interstitial's prose. Cloudflare serves its walls as 403; a 200 with a
|
|
82
|
+
* widget is a page that embeds one. nowsecure.nl is exactly this — 200 to the
|
|
83
|
+
* honest CrawlForge UA, 43 visible characters and two widgets on Cloudflare's
|
|
84
|
+
* test sitekey — and every "Blocked" cell recorded for it came from this
|
|
85
|
+
* module, not from Cloudflare (2026-09-25). The status is what separates it
|
|
86
|
+
* from a widget-only wall, which the length floor cannot; without a status the
|
|
87
|
+
* floor still decides. It still has to be a document — a title and some text —
|
|
88
|
+
* so an empty 200 shell carrying only the widget script stays a wall, as
|
|
89
|
+
* Phase 1 decided.
|
|
90
|
+
*/
|
|
91
|
+
function looksLikeWidgetOnlyPage({ status, title = '', text = '' }) {
|
|
92
|
+
if (status !== 200) return false;
|
|
93
|
+
const visible = String(text).replace(/\s+/g, ' ').trim();
|
|
94
|
+
if (!String(title).trim() || !visible) return false;
|
|
95
|
+
return !INTERSTITIAL_PROSE.test(`${title} ${visible}`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* @param {{ title?: string, html?: string, text?: string, status?: number|null }} page
|
|
80
100
|
* @returns {{ vendor: string, evidence: string } | null}
|
|
81
101
|
*/
|
|
82
102
|
export function detectChallengePage(page = {}) {
|
|
@@ -87,6 +107,7 @@ export function detectChallengePage(page = {}) {
|
|
|
87
107
|
// response input on a short page.
|
|
88
108
|
if (!hit || hit.vendor !== 'cloudflare') return hit;
|
|
89
109
|
if (looksLikeInterstitial(page)) return hit;
|
|
110
|
+
if (looksLikeWidgetOnlyPage(page)) return null;
|
|
90
111
|
return looksLikeRealPage(page) ? null : hit;
|
|
91
112
|
}
|
|
92
113
|
|
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
* documented decision*. That means the override has to leave a trace: which key
|
|
6
6
|
* asked, for which URL, from which tool, when. Without the row the override is
|
|
7
7
|
* a silent product default again, which is the thing G5 exists to prevent.
|
|
8
|
+
* The same trail records each time a request goes out under a browser identity
|
|
9
|
+
* (a stealth render), so that choice is on record too.
|
|
8
10
|
*
|
|
9
11
|
* Rows go to `logs/compliance-audit.log` as JSONL (one row per line, appended)
|
|
10
12
|
* and to a small in-memory ring the tools and tests can read back. Writing is
|
|
@@ -55,6 +57,24 @@ export function recordComplianceEvent(event = {}) {
|
|
|
55
57
|
return row;
|
|
56
58
|
}
|
|
57
59
|
|
|
60
|
+
/**
|
|
61
|
+
* Record that a request is about to go out under a browser identity (a stealth
|
|
62
|
+
* render). Neither the raw key nor the raw owner token is stored: both are
|
|
63
|
+
* reduced to apiKeyId digests. `ownerId` names the website customer behind an
|
|
64
|
+
* internal-proxy request, where `apiKeyId` is always the service's own key.
|
|
65
|
+
* Never throws.
|
|
66
|
+
*/
|
|
67
|
+
export function recordStealthEscalation({ url, tool, engine, apiKey, ownerToken } = {}) {
|
|
68
|
+
return recordComplianceEvent({
|
|
69
|
+
event: 'stealth_escalation',
|
|
70
|
+
url,
|
|
71
|
+
tool,
|
|
72
|
+
engine: engine ?? null,
|
|
73
|
+
apiKeyId: apiKeyId(apiKey),
|
|
74
|
+
...(ownerToken ? { ownerId: apiKeyId(ownerToken) } : {})
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
58
78
|
/** Most recent audit rows, newest last. Test/diagnostic hook. */
|
|
59
79
|
export function getComplianceAuditRows() {
|
|
60
80
|
return ring.slice();
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* impitRung.js — the browser-TLS HTTP try inside the escalation stage
|
|
3
|
+
* (stealth review Phase 7).
|
|
4
|
+
*
|
|
5
|
+
* Some walls refuse Node's TLS handshake whatever the headers and accept a
|
|
6
|
+
* Chrome one: quora.com passed 0/4 over Node TLS and 4/4 over impit's Chrome
|
|
7
|
+
* profile (residential spike, 2026-09-26). impit makes that request without
|
|
8
|
+
* launching a browser, so the escalation stage tries it first and runs the
|
|
9
|
+
* browser only when it does not get the page.
|
|
10
|
+
*
|
|
11
|
+
* Owner decisions this module keeps (docs/STEALTH_REVIEW_2026-09.md, Phase 7):
|
|
12
|
+
* - It runs only inside escalation, whose callers already opted into a browser
|
|
13
|
+
* identity. The plain fetch never uses it.
|
|
14
|
+
* - The User-Agent stays the honest CrawlForge one; only the TLS handshake and
|
|
15
|
+
* impit's profile headers are Chrome's. indeed.com passed 3/4 that way and
|
|
16
|
+
* 0/4 with a Chrome UA.
|
|
17
|
+
* - The price is escalation's own (2 + 5), whichever step got the page.
|
|
18
|
+
*
|
|
19
|
+
* impit is an optional dependency with native bindings. When it is missing or
|
|
20
|
+
* fails to load, `loadImpit()` resolves to null and escalation runs the
|
|
21
|
+
* browser exactly as before.
|
|
22
|
+
*
|
|
23
|
+
* impit resolves DNS itself, outside the undici dispatcher ssrfGuard installs,
|
|
24
|
+
* so redirects are followed here, one hop at a time, and every hop is checked
|
|
25
|
+
* with DNS resolution — the same check safeGoto applies to a navigation.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { load } from 'cheerio';
|
|
29
|
+
import { readBody } from 'crawlforge-extractors';
|
|
30
|
+
import { resolveUserAgent } from './fetchIdentity.js';
|
|
31
|
+
import { assertUrlAllowed } from './ssrfGuard.js';
|
|
32
|
+
import { stealthDocumentVerdict } from './stealthVerdict.js';
|
|
33
|
+
import { config, serverStealthProxies } from '../constants/config.js';
|
|
34
|
+
|
|
35
|
+
/** The engine name escalation reports when this step got the page. */
|
|
36
|
+
export const IMPIT_ENGINE = 'impit';
|
|
37
|
+
|
|
38
|
+
const IMPIT_BROWSER = 'chrome151';
|
|
39
|
+
const IMPIT_TIMEOUT_MS = 15000;
|
|
40
|
+
const MAX_REDIRECTS = 5;
|
|
41
|
+
|
|
42
|
+
// impit reads the HTML the server sent; it runs no JavaScript. quora.com
|
|
43
|
+
// passed the verdict through it with a normal title over 59 characters of
|
|
44
|
+
// "Something went wrong. Wait a moment and try again." — the client app's
|
|
45
|
+
// fallback, not the page (measured 2026-09-26). The browser renders the
|
|
46
|
+
// real page, so a document this thin goes to the browser instead.
|
|
47
|
+
const MIN_UNRENDERED_TEXT_CHARS = 200;
|
|
48
|
+
|
|
49
|
+
let impitClass; // undefined: not tried yet; null: unavailable
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Whether this deployment runs the step. `CRAWLFORGE_IMPIT=off` turns it off.
|
|
53
|
+
* Like the stealth engine, what it can clear depends on the exit IP: from a
|
|
54
|
+
* residential IP it cleared indeed.com, and from the hosted instance's
|
|
55
|
+
* datacenter IP (AS14618, Ashburn) it cleared nothing in 3 runs (2026-09-27),
|
|
56
|
+
* so render.yaml sets it off there. Read on every call, like the stealth
|
|
57
|
+
* proxy list, so an operator can change it without a restart.
|
|
58
|
+
* @returns {boolean}
|
|
59
|
+
*/
|
|
60
|
+
export function impitEnabled() {
|
|
61
|
+
return String(process.env.CRAWLFORGE_IMPIT || '').trim().toLowerCase() !== 'off';
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The Impit class, or null when the optional dependency is absent or its
|
|
66
|
+
* native bindings do not load on this platform. Tried once per process.
|
|
67
|
+
* @returns {Promise<Function|null>}
|
|
68
|
+
*/
|
|
69
|
+
export async function loadImpit() {
|
|
70
|
+
if (impitClass === undefined) {
|
|
71
|
+
try {
|
|
72
|
+
const mod = await import('impit');
|
|
73
|
+
impitClass = mod.Impit ?? mod.default?.Impit ?? null;
|
|
74
|
+
} catch {
|
|
75
|
+
impitClass = null;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return impitClass;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Fetch `url` over a Chrome TLS handshake with the honest User-Agent and
|
|
83
|
+
* return the page when it passes the document verdict, or null when it does
|
|
84
|
+
* not (a wall, an error page, an empty shell) or the request failed. A null
|
|
85
|
+
* means "run the browser"; this function never throws for a failed fetch.
|
|
86
|
+
*
|
|
87
|
+
* An SSRF refusal on any hop DOES throw: the browser would be refused the same
|
|
88
|
+
* URL, so falling through to it would only fail slower.
|
|
89
|
+
*
|
|
90
|
+
* @param {string} url
|
|
91
|
+
* @param {object} [options]
|
|
92
|
+
* @param {Function} [options.Impit] the client class (tests inject a fake)
|
|
93
|
+
* @param {number} [options.timeoutMs]
|
|
94
|
+
* @returns {Promise<{ html: string, text: string, title: string, url: string, status: number, engine: string } | null>}
|
|
95
|
+
*/
|
|
96
|
+
export async function impitFetchPage(url, { Impit, timeoutMs = IMPIT_TIMEOUT_MS } = {}) {
|
|
97
|
+
const ImpitClass = Impit ?? await loadImpit();
|
|
98
|
+
if (!ImpitClass) return null;
|
|
99
|
+
|
|
100
|
+
// The operator's exit address, as the browser would use: a wall that has
|
|
101
|
+
// seen the server's own IP should not see it again from this step.
|
|
102
|
+
const proxyUrl = serverStealthProxies()[0];
|
|
103
|
+
const client = new ImpitClass({
|
|
104
|
+
browser: IMPIT_BROWSER,
|
|
105
|
+
headers: { 'user-agent': resolveUserAgent() },
|
|
106
|
+
followRedirects: false,
|
|
107
|
+
timeout: timeoutMs,
|
|
108
|
+
...(proxyUrl ? { proxyUrl } : {})
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const deadline = Date.now() + timeoutMs;
|
|
112
|
+
let current = url;
|
|
113
|
+
let response;
|
|
114
|
+
for (let hop = 0; ; hop++) {
|
|
115
|
+
await assertUrlAllowed(current, { resolveDns: true });
|
|
116
|
+
const remaining = deadline - Date.now();
|
|
117
|
+
if (remaining <= 0) return null;
|
|
118
|
+
try {
|
|
119
|
+
response = await client.fetch(current, { timeout: remaining });
|
|
120
|
+
} catch {
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
const location = response.status >= 300 && response.status < 400 ? response.headers?.get?.('location') : null;
|
|
124
|
+
if (!location) break;
|
|
125
|
+
if (hop >= MAX_REDIRECTS) return null;
|
|
126
|
+
try {
|
|
127
|
+
current = new URL(location, current).href;
|
|
128
|
+
} catch {
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
if (!/^https?:\/\//i.test(current)) return null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
let html;
|
|
135
|
+
try {
|
|
136
|
+
html = await readBody(response, { maxBytes: config.fetch.maxBodySize });
|
|
137
|
+
} catch {
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const $ = load(html);
|
|
142
|
+
const title = $('title').first().text().trim();
|
|
143
|
+
$('script, style, noscript, template').remove();
|
|
144
|
+
const text = $('body').text().replace(/\s+/g, ' ').trim();
|
|
145
|
+
const verdict = stealthDocumentVerdict(
|
|
146
|
+
{ url: current, title, text, html, status: response.status },
|
|
147
|
+
{ fetcher: 'impit', rendered: false, contentReturned: false }
|
|
148
|
+
);
|
|
149
|
+
if (!verdict.success || text.length < MIN_UNRENDERED_TEXT_CHARS) return null;
|
|
150
|
+
|
|
151
|
+
return { html, text, title, url: current, status: response.status, engine: IMPIT_ENGINE };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Test hook: forget whether impit loaded. */
|
|
155
|
+
export function _resetImpitLoader() {
|
|
156
|
+
impitClass = undefined;
|
|
157
|
+
}
|
package/src/utils/robotsGate.js
CHANGED
|
@@ -24,7 +24,8 @@ import { identityHeaders, resolveUserAgent } from './fetchIdentity.js';
|
|
|
24
24
|
import { throttleHost } from './hostRateLimiter.js';
|
|
25
25
|
import { recordComplianceEvent, apiKeyId } from './complianceAudit.js';
|
|
26
26
|
import { signRequestHeaders } from './webBotAuth.js';
|
|
27
|
-
import { markPreflightRefusal } from '../server/requestContext.js';
|
|
27
|
+
import { markPreflightRefusal, internalOwnerToken } from '../server/requestContext.js';
|
|
28
|
+
import AuthManager from '../core/AuthManager.js';
|
|
28
29
|
import { config } from '../constants/config.js';
|
|
29
30
|
|
|
30
31
|
export class RobotsDisallowedError extends Error {
|
|
@@ -112,11 +113,16 @@ export async function robotsPreflight(url, options = {}) {
|
|
|
112
113
|
? 'respect_robots was disabled for this request. robots.txt did not disallow this URL, so the override changed nothing. The request is recorded against your API key.'
|
|
113
114
|
: `respect_robots was disabled for this request and robots.txt on ${new URL(url).host} disallows this path. Fetching anyway is your decision and is recorded against your API key.`
|
|
114
115
|
);
|
|
116
|
+
// No fetching tool passes its key down to here, so the row falls back to
|
|
117
|
+
// the key this server runs under, as the stealth_escalation row does.
|
|
118
|
+
// Without it every live override was logged as "anonymous".
|
|
119
|
+
const ownerToken = internalOwnerToken();
|
|
115
120
|
recordComplianceEvent({
|
|
116
121
|
event: 'robots_override',
|
|
117
122
|
url,
|
|
118
123
|
tool: options.tool || null,
|
|
119
|
-
apiKeyId: apiKeyId(options.apiKey),
|
|
124
|
+
apiKeyId: apiKeyId(options.apiKey ?? AuthManager.getConfig()?.apiKey),
|
|
125
|
+
...(ownerToken ? { ownerId: apiKeyId(ownerToken) } : {}),
|
|
120
126
|
userAgent,
|
|
121
127
|
robotsAllowed: allowed
|
|
122
128
|
});
|