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.
@@ -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 — 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
+ }