crawlforge-mcp-server 6.6.2 → 6.8.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 +18 -2
- package/README.md +20 -2
- package/package.json +4 -2
- package/server.js +37 -16
- package/src/constants/config.js +22 -0
- package/src/core/ResearchOrchestrator.js +76 -2
- package/src/core/StealthBrowserManager.js +1159 -247
- package/src/core/creatorMode.js +1 -1
- package/src/core/processing/BrowserProcessor.js +30 -4
- package/src/skills/agent-skills/crawlforge-stealth-browsing/SKILL.md +57 -9
- 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/scrape/escalation.js +6 -1
- package/src/tools/scrape/unifiedScrape.js +2 -1
- package/src/utils/challengeDetection.js +86 -3
- package/src/utils/firefoxPageErrorGuard.js +93 -0
- package/src/utils/stealthVerdict.js +36 -2
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stops a Firefox page's own JavaScript error from taking the process down.
|
|
3
|
+
*
|
|
4
|
+
* Camoufox's Juggler reports an uncaught page error without a `location`:
|
|
5
|
+
*
|
|
6
|
+
* ◀ RECV {"method":"Page.uncaughtError","params":{
|
|
7
|
+
* "frameId":"mainframe-8",
|
|
8
|
+
* "message":"TypeError: null has no properties",
|
|
9
|
+
* "stack":"@http://127.0.0.1:55712/:3:13\n"}}
|
|
10
|
+
*
|
|
11
|
+
* playwright-core 1.62's FFPage._onUncaughtError forwards that missing
|
|
12
|
+
* `params.location` into Page.addPageError, and the BrowserContext dispatcher
|
|
13
|
+
* then reads it unconditionally:
|
|
14
|
+
*
|
|
15
|
+
* location: { url: pageError.location.url, ... }
|
|
16
|
+
*
|
|
17
|
+
* The resulting "Cannot read properties of undefined (reading 'url')" is thrown
|
|
18
|
+
* inside the protocol dispatch loop, where nothing awaits it — it arrives as an
|
|
19
|
+
* uncaughtException. A plain `<script>null.boom;</script>` on any page is enough
|
|
20
|
+
* to trigger it, and bot.sannysoft.com does it in the ordinary course of
|
|
21
|
+
* running its checks.
|
|
22
|
+
*
|
|
23
|
+
* Under server.js the catch-all uncaughtException handler absorbs it and the
|
|
24
|
+
* scrape still returns, but every other consumer — the CLI, deep_research when
|
|
25
|
+
* it is embedded, any direct use of StealthBrowserManager — dies on the spot.
|
|
26
|
+
* Leaning on that handler is not a fix either: it logs a stack trace on a page
|
|
27
|
+
* that did nothing wrong, and Node treats the process state as undefined
|
|
28
|
+
* afterwards.
|
|
29
|
+
*
|
|
30
|
+
* So the missing value is filled in at its source. Firefox told us a page error
|
|
31
|
+
* happened but not where; the zeroed location says exactly that, and everything
|
|
32
|
+
* downstream — including the `pageerror` event a caller can listen for —
|
|
33
|
+
* behaves normally. Chromium and WebKit always send a location, so they never
|
|
34
|
+
* reach the fallback.
|
|
35
|
+
*
|
|
36
|
+
* Reaching into playwright's internals is deliberate and bounded: this patches
|
|
37
|
+
* one method, only when it exists and has not already been patched, and any
|
|
38
|
+
* failure leaves the original in place. A playwright that fixes this upstream,
|
|
39
|
+
* or renames the method, turns it into a no-op rather than a breakage. The
|
|
40
|
+
* `playwright-core/lib/coreBundle` specifier is one playwright-core publishes in
|
|
41
|
+
* its own `exports` map, and it resolves to the same module instance playwright
|
|
42
|
+
* itself loads. playwright-core is not declared as a dependency on purpose: it
|
|
43
|
+
* must stay the exact version playwright pins, so it is only ever reached
|
|
44
|
+
* through the copy playwright brought.
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
import { createRequire } from 'module';
|
|
48
|
+
|
|
49
|
+
const require = createRequire(import.meta.url);
|
|
50
|
+
|
|
51
|
+
// Firefox reports that an error happened but not where. Zeros say "unknown"
|
|
52
|
+
// without inventing a file or a line number.
|
|
53
|
+
const UNKNOWN_LOCATION = Object.freeze({ url: '', lineNumber: 0, columnNumber: 0 });
|
|
54
|
+
|
|
55
|
+
let applied = false;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Idempotent, best-effort, and safe to call before every Firefox/Camoufox
|
|
59
|
+
* launch. Returns true when the guard is in place (including when a previous
|
|
60
|
+
* call installed it), false when this playwright build did not match.
|
|
61
|
+
*/
|
|
62
|
+
export function guardFirefoxPageErrors() {
|
|
63
|
+
if (applied) return true;
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
const bundle = require('playwright-core/lib/coreBundle');
|
|
67
|
+
const prototype = bundle?.server?.Page?.prototype;
|
|
68
|
+
if (!prototype || typeof prototype.addPageError !== 'function') return false;
|
|
69
|
+
if (prototype.addPageError.__crawlforgeGuarded) {
|
|
70
|
+
applied = true;
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const original = prototype.addPageError;
|
|
75
|
+
function addPageError(error, location) {
|
|
76
|
+
return original.call(this, error, location || UNKNOWN_LOCATION);
|
|
77
|
+
}
|
|
78
|
+
addPageError.__crawlforgeGuarded = true;
|
|
79
|
+
prototype.addPageError = addPageError;
|
|
80
|
+
|
|
81
|
+
applied = true;
|
|
82
|
+
return true;
|
|
83
|
+
} catch {
|
|
84
|
+
// A playwright that moved or sealed this is not a reason to fail a launch;
|
|
85
|
+
// the crash it prevents is rarer than the launch it would break.
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Test seam: forget that the guard was installed. Does not un-patch. */
|
|
91
|
+
export function _resetFirefoxPageErrorGuard() {
|
|
92
|
+
applied = false;
|
|
93
|
+
}
|
|
@@ -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
|
+
}
|