crawlforge-mcp-server 6.7.0 → 6.9.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 +19 -2
- package/README.md +21 -3
- package/package.json +4 -2
- package/server.js +51 -27
- package/src/constants/config.js +22 -0
- package/src/core/AgentOrchestrator.js +123 -25
- package/src/core/AuthManager.js +10 -1
- package/src/core/ResearchOrchestrator.js +72 -2
- package/src/core/StealthBrowserManager.js +852 -250
- package/src/core/creatorMode.js +1 -1
- package/src/core/processing/BrowserProcessor.js +30 -4
- package/src/skills/agent-skills/crawlforge-deep-research/SKILL.md +1 -1
- package/src/skills/agent-skills/crawlforge-stealth-browsing/SKILL.md +35 -11
- package/src/skills/agent-skills/crawlforge-stealth-browsing/references/engine-selection.md +64 -19
- package/src/skills/agent-skills/crawlforge-web-scraping/SKILL.md +12 -6
- package/src/tools/advanced/BrowserSessionTool.js +38 -3
- package/src/tools/advanced/ScrapeWithActionsTool.js +30 -2
- package/src/tools/agent/agent.js +29 -9
- package/src/tools/agent/escalation.js +40 -0
- package/src/tools/scrape/escalation.js +6 -1
- package/src/tools/scrape/unifiedScrape.js +2 -1
- package/src/utils/challengeDetection.js +86 -3
- package/src/utils/stealthVerdict.js +36 -2
|
@@ -1,10 +1,93 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* challengeDetection.js —
|
|
2
|
+
* challengeDetection.js — the challenge-vendor tables, refined for widgets.
|
|
3
3
|
*
|
|
4
4
|
* The tables moved to crawlforge-extractors 1.7.0 so the REST `scrape` route
|
|
5
5
|
* reaches the same verdict as the MCP server (Phase 0, 0.1). Callers keep
|
|
6
6
|
* importing from here.
|
|
7
|
+
*
|
|
8
|
+
* Upstream treats a vendor marker on a page under 4000 visible characters as
|
|
9
|
+
* definitive, which reports a block on a real page that merely embeds a
|
|
10
|
+
* Turnstile widget: quora.com rendered its real 397-character login page on
|
|
11
|
+
* both engines and the verdict layer still called it blocked (2026-09-21
|
|
12
|
+
* benchmark, finding 6). A widget embed and an interstitial are separable by
|
|
13
|
+
* what else is in the document, not by length — the interstitial carries
|
|
14
|
+
* Cloudflare's challenge bootstrap or the prose it shows while it holds the
|
|
15
|
+
* request, and a page that merely embeds the widget carries neither. So the
|
|
16
|
+
* refinement below suppresses that one case and leaves every other path
|
|
17
|
+
* upstream owns (a challenge title, Amazon's validateCaptcha form, DataDome,
|
|
18
|
+
* PerimeterX, Akamai) exactly as it is. Worth upstreaming.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { detectChallengePage as detectUpstream } from 'crawlforge-extractors';
|
|
22
|
+
|
|
23
|
+
// The bootstrap a real interstitial ships and a page embedding a widget never
|
|
24
|
+
// does: Cloudflare's challenge options blob and its cf-chl- element ids,
|
|
25
|
+
// Vercel's checkpoint script and token. Two Cloudflare tokens are deliberately
|
|
26
|
+
// NOT here, because a real page legitimately carries both:
|
|
27
|
+
// - challenges.cloudflare.com, the widget's own script host;
|
|
28
|
+
// - cf-chl-widget-*, the hidden response input Turnstile names itself.
|
|
29
|
+
// quora.com's login page carries exactly one cf-chl- token and it is
|
|
30
|
+
// "cf-chl-widget-gaztz_response" (measured 2026-09-21), so a bare cf-chl- test
|
|
31
|
+
// matches every widget embed there is. Hence the lookahead — do not put
|
|
32
|
+
// cf-chl- back.
|
|
33
|
+
const INTERSTITIAL_MARKERS = [
|
|
34
|
+
{ vendor: 'cloudflare', markers: /_cf_chl_opt|window\._cf_chl|cf-chl-(?!widget)|cf_chl_rc_/i, evidence: 'the Cloudflare challenge bootstrap' },
|
|
35
|
+
{ vendor: 'vercel', markers: /_vercel\/challenge|vercel\.link\/security-checkpoint|x-vercel-challenge-token/i, evidence: 'the Vercel Security Checkpoint bootstrap' }
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
// What an interstitial says to the reader while it holds the request.
|
|
39
|
+
const INTERSTITIAL_PROSE = /verifying you are human|checking your browser|needs to review the security of your connection|enable javascript and cookies to continue/i;
|
|
40
|
+
|
|
41
|
+
// The floor for "this document has a real body". quora.com's real login page
|
|
42
|
+
// is 397 visible characters, so the floor has to sit below that; Cloudflare's
|
|
43
|
+
// own interstitial renders well under 200 characters of prose ("Verifying you
|
|
44
|
+
// are human…", "Enable JavaScript and cookies to continue"), so a genuinely
|
|
45
|
+
// empty wall carrying only a widget marker is still short enough to be caught.
|
|
46
|
+
const REAL_BODY_MIN_CHARS = 200;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Whether a document is a challenge wall rather than the page: a known
|
|
50
|
+
* challenge title OR a challenge bootstrap marker, regardless of the title and
|
|
51
|
+
* regardless of page length, so a custom-titled interstitial (nowsecure.nl,
|
|
52
|
+
* whose title is just "nowsecure.nl") is recognised too.
|
|
53
|
+
*
|
|
54
|
+
* @param {{ title?: string, html?: string, text?: string }} page
|
|
55
|
+
* @returns {{ vendor: string, evidence: string } | null}
|
|
56
|
+
*/
|
|
57
|
+
export function looksLikeInterstitial(page = {}) {
|
|
58
|
+
const { title = '', html = '' } = page;
|
|
59
|
+
// A title on its own reaches only upstream's title branch — its marker
|
|
60
|
+
// branch needs html and its definitive branch a form — so the challenge
|
|
61
|
+
// titles stay in one place rather than being copied here.
|
|
62
|
+
const byTitle = detectUpstream({ title });
|
|
63
|
+
if (byTitle) return byTitle;
|
|
64
|
+
for (const { vendor, markers, evidence } of INTERSTITIAL_MARKERS) {
|
|
65
|
+
if (markers.test(html)) return { vendor, evidence };
|
|
66
|
+
}
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** A title, a body a reader could read, and none of the interstitial's prose. */
|
|
71
|
+
function looksLikeRealPage({ title = '', text = '' }) {
|
|
72
|
+
if (!String(title).trim()) return false;
|
|
73
|
+
const visible = String(text).replace(/\s+/g, ' ').trim();
|
|
74
|
+
if (visible.length < REAL_BODY_MIN_CHARS) return false;
|
|
75
|
+
return !INTERSTITIAL_PROSE.test(`${title} ${visible}`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* @param {{ title?: string, html?: string, text?: string }} page
|
|
80
|
+
* @returns {{ vendor: string, evidence: string } | null}
|
|
7
81
|
*/
|
|
82
|
+
export function detectChallengePage(page = {}) {
|
|
83
|
+
const hit = detectUpstream(page);
|
|
84
|
+
// Cloudflare is the only vendor whose markers a real page can carry, so it is
|
|
85
|
+
// the only one refined. Reaching here with a cloudflare hit that is not an
|
|
86
|
+
// interstitial means the evidence was the widget's own script host or
|
|
87
|
+
// response input on a short page.
|
|
88
|
+
if (!hit || hit.vendor !== 'cloudflare') return hit;
|
|
89
|
+
if (looksLikeInterstitial(page)) return hit;
|
|
90
|
+
return looksLikeRealPage(page) ? null : hit;
|
|
91
|
+
}
|
|
8
92
|
|
|
9
|
-
export
|
|
10
|
-
export { detectChallengePage as default } from 'crawlforge-extractors';
|
|
93
|
+
export default detectChallengePage;
|
|
@@ -1,10 +1,44 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* stealthVerdict.js —
|
|
2
|
+
* stealthVerdict.js — the document verdict, with the widget refinement.
|
|
3
3
|
*
|
|
4
4
|
* `stealthDocumentVerdict` moved to crawlforge-extractors 1.7.0 as
|
|
5
5
|
* `documentVerdict` — the same behaviour and messages for the stealth callers,
|
|
6
6
|
* plus a `fetcher` option so the plain `scrape` path on both surfaces reaches
|
|
7
7
|
* the same verdict (Phase 0, 0.1). Callers keep importing from here.
|
|
8
|
+
*
|
|
9
|
+
* Upstream decides the block before every other rule it owns, so the widget
|
|
10
|
+
* refinement in challengeDetection.js would never reach the verdict the tools
|
|
11
|
+
* and the benchmark actually print. The wrapper below re-runs upstream on the
|
|
12
|
+
* same document with the widget marker neutralised whenever the refinement
|
|
13
|
+
* clears the block, so an HTTP error page, an empty shell or a short
|
|
14
|
+
* error-titled placeholder that happens to embed a widget is still named by
|
|
15
|
+
* upstream and still decides (Phase 1, finding 6).
|
|
8
16
|
*/
|
|
9
17
|
|
|
10
|
-
|
|
18
|
+
import { documentVerdict, SOFT_ERROR_MAX_CHARS } from 'crawlforge-extractors';
|
|
19
|
+
import { detectChallengePage } from './challengeDetection.js';
|
|
20
|
+
|
|
21
|
+
export { SOFT_ERROR_MAX_CHARS };
|
|
22
|
+
|
|
23
|
+
// The two Turnstile markers challengeDetection.js clears: the widget's script
|
|
24
|
+
// host, and the hidden response input the widget names itself
|
|
25
|
+
// (id="cf-chl-widget-<id>_response" — quora.com, measured 2026-09-21).
|
|
26
|
+
// Upstream's Cloudflare pattern matches a bare cf-chl-, so neutralising the
|
|
27
|
+
// host alone left the re-run blocking the same document a second time. Both
|
|
28
|
+
// are swapped for inert text; the bootstrap markers are never touched, so a
|
|
29
|
+
// document that carries one is still upstream's block to make.
|
|
30
|
+
const TURNSTILE_WIDGET = /challenges\.cloudflare\.com|cf-chl-widget/gi;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @param {{ url?: string, title?: string, text?: string, html?: string, status?: number|null }} scraped
|
|
34
|
+
* @param {{ waitedMs?: number, allowEmpty?: boolean, fetcher?: string, rendered?: boolean, contentReturned?: boolean }} [options]
|
|
35
|
+
* @returns {{ success: boolean, status: number|null, error?: string, blocked?: { vendor: string, evidence: string } }}
|
|
36
|
+
*/
|
|
37
|
+
export function stealthDocumentVerdict(scraped, options) {
|
|
38
|
+
const verdict = documentVerdict(scraped, options);
|
|
39
|
+
if (!verdict.blocked || detectChallengePage(scraped || {})) return verdict;
|
|
40
|
+
// The neutralised copy is read by upstream and discarded; the caller's own
|
|
41
|
+
// document is never rewritten.
|
|
42
|
+
const html = String(scraped?.html || '').replace(TURNSTILE_WIDGET, 'turnstile-widget');
|
|
43
|
+
return documentVerdict({ ...scraped, html }, options);
|
|
44
|
+
}
|