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.
@@ -1,10 +1,93 @@
1
1
  /**
2
- * challengeDetection.js — re-export of the challenge-vendor tables.
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 { detectChallengePage } from 'crawlforge-extractors';
10
- export { detectChallengePage as default } from 'crawlforge-extractors';
93
+ export default detectChallengePage;
@@ -1,10 +1,44 @@
1
1
  /**
2
- * stealthVerdict.js — re-export of the document verdict.
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
- export { documentVerdict as stealthDocumentVerdict, SOFT_ERROR_MAX_CHARS } from 'crawlforge-extractors';
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
+ }