crawlforge-mcp-server 6.14.0 → 6.17.1
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/README.md +1 -1
- package/package.json +3 -3
- package/server.js +74 -22
- package/src/cli/commands/stealth.js +2 -2
- package/src/core/ActionExecutor.js +219 -39
- package/src/core/AuthManager.js +12 -0
- package/src/core/LLMsTxtAnalyzer.js +1 -0
- package/src/core/ResearchOrchestrator.js +10 -4
- package/src/core/StealthBrowserManager.js +117 -13
- package/src/core/browser/actionQueue.js +4 -1
- package/src/core/browser/windowState.js +88 -0
- package/src/core/crawlers/BFSCrawler.js +12 -3
- package/src/core/processing/BrowserProcessor.js +27 -8
- package/src/core/processing/PDFProcessor.js +15 -6
- package/src/server/fallbackHints.js +7 -4
- package/src/server/inlineThreshold.js +81 -19
- package/src/skills/agent-skills/crawlforge-getting-started/references/credits.md +1 -1
- package/src/skills/agent-skills/crawlforge-structured-extraction/SKILL.md +25 -6
- package/src/tools/advanced/BrowserSessionTool.js +38 -13
- package/src/tools/advanced/ScrapeWithActionsTool.js +40 -4
- package/src/tools/advanced/batchScrape/worker.js +3 -1
- package/src/tools/basic/_fetch.js +3 -1
- package/src/tools/crawl/_sessionContext.js +2 -1
- package/src/tools/crawl/mapSite.js +2 -1
- package/src/tools/extract/_fetchAndParse.js +1 -0
- package/src/tools/extract/extractContent.js +2 -1
- package/src/tools/extract/extractEmbeddedState.js +320 -71
- package/src/tools/extract/processDocument.js +5 -3
- package/src/tools/llmstxt/generateLLMsTxt.js +2 -1
- package/src/tools/templates/ScrapeTemplateTool.js +11 -3
- package/src/tools/tracking/trackChanges/differ.js +1 -0
- package/src/utils/complianceAudit.js +35 -5
- package/src/utils/impitRung.js +16 -5
- package/src/utils/resignedFetch.js +165 -0
- package/src/utils/robotsChecker.js +6 -1
- package/src/utils/robotsGate.js +124 -11
- package/src/utils/secretMask.js +1 -1
- package/src/utils/sitemapParser.js +2 -1
- package/src/utils/ssrfGuard.js +88 -11
|
@@ -17,11 +17,12 @@ import path from 'path';
|
|
|
17
17
|
import { createRequire } from 'module';
|
|
18
18
|
import HumanBehaviorSimulator from '../utils/HumanBehaviorSimulator.js';
|
|
19
19
|
import { BrowserContextPool } from './BrowserContextPool.js';
|
|
20
|
-
import { safeGoto } from '../utils/ssrfGuard.js';
|
|
20
|
+
import { safeGoto, assertNavigationAllowed, assertUrlAllowed } from '../utils/ssrfGuard.js';
|
|
21
21
|
import { looksLikeInterstitial, detectChallengePage } from '../utils/challengeDetection.js';
|
|
22
22
|
import { guardFirefoxPageErrors } from '../utils/firefoxPageErrorGuard.js';
|
|
23
23
|
import { serverStealthProxies } from '../constants/config.js';
|
|
24
24
|
import { ClearanceJar, sharedClearanceJar } from './ClearanceJar.js';
|
|
25
|
+
import { readWindowState } from './browser/windowState.js';
|
|
25
26
|
|
|
26
27
|
// Grace given to a document that rendered no title and no text (see _waitOutEmptyDocument).
|
|
27
28
|
export const EMPTY_DOCUMENT_GRACE_MS = 8000;
|
|
@@ -158,6 +159,39 @@ function isChromium(page) {
|
|
|
158
159
|
}
|
|
159
160
|
}
|
|
160
161
|
|
|
162
|
+
/**
|
|
163
|
+
* A proxy entry with its credentials removed. Everything before the last "@"
|
|
164
|
+
* of the authority goes, with or without a scheme: parseProxyEntry accepts a
|
|
165
|
+
* bare `user:pass@host:port`, which a rule anchored on "//" left whole.
|
|
166
|
+
* @param {string} entry
|
|
167
|
+
* @returns {string}
|
|
168
|
+
*/
|
|
169
|
+
export function redactProxyEntry(entry) {
|
|
170
|
+
return String(entry).replace(/^([a-z][a-z0-9+.-]*:\/\/)?[^/]*@/i, '$1');
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Refuse a per-call proxy list on camoufox. camoufox takes its proxy at
|
|
175
|
+
* browser launch and that one browser serves every later camoufox call, so a
|
|
176
|
+
* caller's proxy would carry other callers' traffic, on the caller's
|
|
177
|
+
* credentials, or be ignored when a browser is already up. Chromium sets the
|
|
178
|
+
* proxy per context. Called with the engine that will actually run, so an
|
|
179
|
+
* "auto" that lands on camoufox is refused too. The operator's
|
|
180
|
+
* CRAWLFORGE_STEALTH_PROXIES is not a per-call list and is not refused.
|
|
181
|
+
* @param {string} engine - the resolved engine
|
|
182
|
+
* @param {{ proxyRotation?: { enabled?: boolean, proxies?: string[] } }} config
|
|
183
|
+
* @throws {Error}
|
|
184
|
+
*/
|
|
185
|
+
export function assertProxyEngineAllowed(engine, config) {
|
|
186
|
+
const requested = config?.proxyRotation?.enabled ? (config.proxyRotation.proxies || []) : [];
|
|
187
|
+
if (engine !== 'camoufox' || !requested.length) return;
|
|
188
|
+
throw new Error(
|
|
189
|
+
'proxyRotation is refused on the camoufox engine: camoufox applies its proxy at browser launch ' +
|
|
190
|
+
'and shares that browser across calls, so a per-call proxy cannot be kept to this call. Use ' +
|
|
191
|
+
'engine:"chromium" (proxies are per context there), or set the operator list CRAWLFORGE_STEALTH_PROXIES.'
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
161
195
|
/**
|
|
162
196
|
* Resolve a requested engine to the one that will actually launch.
|
|
163
197
|
*
|
|
@@ -421,12 +455,19 @@ export class StealthBrowserManager {
|
|
|
421
455
|
*/
|
|
422
456
|
async launchStealthBrowser(config = {}) {
|
|
423
457
|
const validatedConfig = StealthConfigSchema.parse({ ...this.defaultConfig, ...config });
|
|
458
|
+
// Every path to a browser comes through here, createStealthContext's
|
|
459
|
+
// included, so this is where a caller's proxy list is held to the SSRF
|
|
460
|
+
// guard before either engine is handed it.
|
|
461
|
+
await this.assertCallerProxiesAllowed(validatedConfig);
|
|
424
462
|
// 'auto' has to become a concrete engine before the comparison below: an
|
|
425
463
|
// unresolved 'auto' never equals _launchedEngine, so every call would park
|
|
426
464
|
// a perfectly good browser and launch a second one beside it. This is the
|
|
427
465
|
// call that decides the engine — every other path reaches a browser
|
|
428
466
|
// through it — so it is the one that records the outcome.
|
|
429
467
|
await this._resolveConfigEngine(validatedConfig, true);
|
|
468
|
+
// The tools refuse this up front; this is the backstop for every other
|
|
469
|
+
// path, decided on the engine 'auto' resolved to, before any launch.
|
|
470
|
+
assertProxyEngineAllowed(validatedConfig.engine, validatedConfig);
|
|
430
471
|
|
|
431
472
|
// A Chromium that was OOM-killed or crashed doesn't error on reuse — its
|
|
432
473
|
// protocol calls hang. Detect the corpse and relaunch instead.
|
|
@@ -527,7 +568,11 @@ export class StealthBrowserManager {
|
|
|
527
568
|
// leave camoufox reporting the first proxy's city behind the second
|
|
528
569
|
// proxy's exit IP, which is a worse signal than not rotating at all.
|
|
529
570
|
// A rotation takes effect on the next launch, after cleanup().
|
|
530
|
-
|
|
571
|
+
//
|
|
572
|
+
// Only the operator's CRAWLFORGE_STEALTH_PROXIES, never a caller's list:
|
|
573
|
+
// this browser outlives the call, and every later camoufox context
|
|
574
|
+
// inherits its proxy (assertProxyEngineAllowed refuses the caller's).
|
|
575
|
+
const proxy = this.resolveProxy({ ...validatedConfig, proxyRotation: undefined });
|
|
531
576
|
// The caller's locale goes to the launcher, not to the context. camoufox
|
|
532
577
|
// sets language, Accept-Language and Intl together below the JS layer,
|
|
533
578
|
// where a Worker reads the same answer as the document; Playwright's
|
|
@@ -2386,18 +2431,23 @@ export class StealthBrowserManager {
|
|
|
2386
2431
|
// Wait for potential challenge page
|
|
2387
2432
|
await page.waitForTimeout(2000);
|
|
2388
2433
|
|
|
2389
|
-
// Check for CloudFlare challenge indicators
|
|
2434
|
+
// Check for CloudFlare challenge indicators. Cloudflare's current
|
|
2435
|
+
// interstitial reads "Performing security verification" under the title
|
|
2436
|
+
// "Just a moment..." (doordash.com, 2026-10-03); none of the older
|
|
2437
|
+
// phrases below appear on it, so the wait further down ended at once.
|
|
2390
2438
|
const challengeDetected = await page.evaluate(() => {
|
|
2391
2439
|
const indicators = [
|
|
2392
2440
|
'cf-browser-verification',
|
|
2393
2441
|
'cf-challenge-running',
|
|
2394
2442
|
'Checking your browser',
|
|
2443
|
+
'Performing security verification',
|
|
2395
2444
|
'DDoS protection by Cloudflare',
|
|
2396
2445
|
'Ray ID'
|
|
2397
2446
|
];
|
|
2398
2447
|
|
|
2399
2448
|
const pageText = document.body.innerText;
|
|
2400
|
-
return
|
|
2449
|
+
return /^just a moment/i.test(document.title.trim()) ||
|
|
2450
|
+
indicators.some(indicator => pageText.includes(indicator));
|
|
2401
2451
|
});
|
|
2402
2452
|
|
|
2403
2453
|
if (challengeDetected) {
|
|
@@ -2419,16 +2469,25 @@ export class StealthBrowserManager {
|
|
|
2419
2469
|
}
|
|
2420
2470
|
}
|
|
2421
2471
|
|
|
2422
|
-
// Wait for challenge to complete
|
|
2472
|
+
// Wait for the challenge to complete, up to 10 s. Only waits for
|
|
2473
|
+
// Cloudflare's own script to finish; nothing is clicked. A challenge
|
|
2474
|
+
// that passes clears in seconds; one that will not pass (an
|
|
2475
|
+
// interactive check, or this IP's reputation) never clears, and 30 s
|
|
2476
|
+
// there put a walled chain past the REST window (doordash.com stealth
|
|
2477
|
+
// Chromium, 3 of 3 runs walled at 39 s with the 30 s cap, 2026-10-03).
|
|
2423
2478
|
await page.waitForFunction(() => {
|
|
2424
2479
|
const indicators = [
|
|
2425
2480
|
'cf-browser-verification',
|
|
2426
2481
|
'cf-challenge-running',
|
|
2427
|
-
'Checking your browser'
|
|
2482
|
+
'Checking your browser',
|
|
2483
|
+
'Performing security verification'
|
|
2428
2484
|
];
|
|
2429
|
-
const pageText = document.body.innerText;
|
|
2430
|
-
return
|
|
2431
|
-
|
|
2485
|
+
const pageText = document.body ? document.body.innerText : '';
|
|
2486
|
+
return !/^just a moment/i.test(document.title.trim()) &&
|
|
2487
|
+
!indicators.some(indicator => pageText.includes(indicator));
|
|
2488
|
+
// Options are the third argument; in the second they were the page
|
|
2489
|
+
// function's argument and the wait ran Playwright's 30 s default.
|
|
2490
|
+
}, undefined, { timeout: 10000 }).catch(() => {});
|
|
2432
2491
|
|
|
2433
2492
|
this.performanceMetrics.successfulBypasses++;
|
|
2434
2493
|
return true;
|
|
@@ -2549,9 +2608,34 @@ export class StealthBrowserManager {
|
|
|
2549
2608
|
return proxy;
|
|
2550
2609
|
}
|
|
2551
2610
|
|
|
2611
|
+
/**
|
|
2612
|
+
* Refuse a caller's proxy whose host the SSRF guard would refuse as a page:
|
|
2613
|
+
* a proxy on loopback, link-local or a metadata address (RFC 1918 too under
|
|
2614
|
+
* SSRF_STRICT, and ALLOWED_DOMAINS exempt, as everywhere else) would have
|
|
2615
|
+
* the browser send the call's traffic into this server's own network. Only
|
|
2616
|
+
* the per-call list is checked — CRAWLFORGE_STEALTH_PROXIES is the
|
|
2617
|
+
* operator's, who may well run a proxy on localhost. The message names the
|
|
2618
|
+
* host, never the entry, which carries the password.
|
|
2619
|
+
* @param {{ proxyRotation?: { enabled?: boolean, proxies?: string[] } }} config
|
|
2620
|
+
* @returns {Promise<void>}
|
|
2621
|
+
* @throws {Error} code SSRF_BLOCKED
|
|
2622
|
+
*/
|
|
2623
|
+
async assertCallerProxiesAllowed(config) {
|
|
2624
|
+
const requested = config.proxyRotation?.enabled ? (config.proxyRotation.proxies || []) : [];
|
|
2625
|
+
for (const entry of requested) {
|
|
2626
|
+
// url.host of socks4/socks5 parses the same way; the guard only takes http(s).
|
|
2627
|
+
const { host } = new URL(this.parseProxyEntry(entry).server);
|
|
2628
|
+
try {
|
|
2629
|
+
await assertUrlAllowed(`http://${host}/`, { resolveDns: true });
|
|
2630
|
+
} catch (err) {
|
|
2631
|
+
throw Object.assign(new Error(`proxyRotation: proxy host ${host} refused — ${err.message}`), { code: err.code });
|
|
2632
|
+
}
|
|
2633
|
+
}
|
|
2634
|
+
}
|
|
2635
|
+
|
|
2552
2636
|
/** A proxy entry with its credentials removed, for logs and get_stats. */
|
|
2553
2637
|
redactProxy(entry) {
|
|
2554
|
-
return
|
|
2638
|
+
return redactProxyEntry(entry);
|
|
2555
2639
|
}
|
|
2556
2640
|
|
|
2557
2641
|
/**
|
|
@@ -2844,7 +2928,16 @@ export class StealthBrowserManager {
|
|
|
2844
2928
|
return this._clickTurnstile(page);
|
|
2845
2929
|
}
|
|
2846
2930
|
|
|
2847
|
-
|
|
2931
|
+
/**
|
|
2932
|
+
* @param {object} options
|
|
2933
|
+
* @param {boolean} [options.readWindowState] also read the framework globals
|
|
2934
|
+
* off `window` once the page has settled (extract_embedded_state's
|
|
2935
|
+
* escalation, src/core/browser/windowState.js); returned as `windowState`
|
|
2936
|
+
* @param {(to: string) => Promise<void>} [options.onRedirect] the caller's
|
|
2937
|
+
* gate for wherever the page is redirected to (robotsGate.js
|
|
2938
|
+
* `redirectGate`); a refusal throws and nothing of the page is returned
|
|
2939
|
+
*/
|
|
2940
|
+
async scrapeWithStealth({ url, engine, wait_for = 0, screenshot = false, stealthConfig = {}, readWindowState: wantWindowState = false, onRedirect } = {}) {
|
|
2848
2941
|
if (!url) throw new Error('scrapeWithStealth requires a url');
|
|
2849
2942
|
|
|
2850
2943
|
const { contextId, engineFallbackWarning = null } = await this.createStealthContext({ ...stealthConfig, engine });
|
|
@@ -2870,7 +2963,8 @@ export class StealthBrowserManager {
|
|
|
2870
2963
|
});
|
|
2871
2964
|
// SSRF guard at the navigation boundary: the stealth engine resolves DNS
|
|
2872
2965
|
// itself, so a URL/host-only check would miss rebinding to private/metadata IPs.
|
|
2873
|
-
const response = await safeGoto(page, url, { waitUntil: 'domcontentloaded' });
|
|
2966
|
+
const response = await safeGoto(page, url, { waitUntil: 'domcontentloaded', onRedirect });
|
|
2967
|
+
const landedUrl = page.url();
|
|
2874
2968
|
if (response && status === null) status = response.status();
|
|
2875
2969
|
if (wait_for > 0) await page.waitForTimeout(wait_for);
|
|
2876
2970
|
// Cloudflare's and Vercel's JavaScript challenges solve themselves in a
|
|
@@ -2888,6 +2982,10 @@ export class StealthBrowserManager {
|
|
|
2888
2982
|
// a successful scrape.
|
|
2889
2983
|
const renderMs = await this._settleRender(page);
|
|
2890
2984
|
const gracedMs = emptyGraceMs + renderMs;
|
|
2985
|
+
// The waits above are time in which a challenge or a client-side redirect
|
|
2986
|
+
// can move the page on. The document read below is the one that has to
|
|
2987
|
+
// pass, so wherever the page stands now is checked like a redirect hop.
|
|
2988
|
+
await assertNavigationAllowed(page, landedUrl, null, onRedirect);
|
|
2891
2989
|
|
|
2892
2990
|
// A failure to read the document is a failure. With every read wrapped
|
|
2893
2991
|
// in .catch(() => ''), a renderer that crashed or a page closed during
|
|
@@ -2913,6 +3011,12 @@ export class StealthBrowserManager {
|
|
|
2913
3011
|
const shot = screenshot
|
|
2914
3012
|
? await page.screenshot({ encoding: 'base64', fullPage: false }).catch(() => null)
|
|
2915
3013
|
: null;
|
|
3014
|
+
let windowState;
|
|
3015
|
+
if (wantWindowState) {
|
|
3016
|
+
const read = await readWindowState(page);
|
|
3017
|
+
windowState = read.state;
|
|
3018
|
+
warnings.push(...read.warnings);
|
|
3019
|
+
}
|
|
2916
3020
|
|
|
2917
3021
|
// Still a wall after everything above: whatever clearance this context
|
|
2918
3022
|
// carried for the site did not work, so closeContext drops it instead of
|
|
@@ -2921,7 +3025,7 @@ export class StealthBrowserManager {
|
|
|
2921
3025
|
this._markBlocked(contextId, [url, page.url()]);
|
|
2922
3026
|
}
|
|
2923
3027
|
|
|
2924
|
-
return { success: true, url, title, text, html, screenshot: shot, status, gracedMs, engine: engineUsed, warnings };
|
|
3028
|
+
return { success: true, url, title, text, html, screenshot: shot, status, gracedMs, engine: engineUsed, warnings, ...(windowState ? { windowState } : {}) };
|
|
2925
3029
|
} finally {
|
|
2926
3030
|
await this.closeContext(contextId).catch(() => {});
|
|
2927
3031
|
}
|
|
@@ -57,7 +57,10 @@ export function createActionQueue({
|
|
|
57
57
|
'CRAWLFORGE_MAX_ACTION_SESSIONS / CRAWLFORGE_ACTION_QUEUE_TIMEOUT_MS if the machine has room.'
|
|
58
58
|
));
|
|
59
59
|
}, timeoutMs);
|
|
60
|
-
|
|
60
|
+
// Not unref()'d: a caller is awaiting this wait, so it must keep the event
|
|
61
|
+
// loop alive. Unref'd, Node 22 could exit mid-wait with the promise still
|
|
62
|
+
// pending (it did, under node:test in CI). The timer never outlives the
|
|
63
|
+
// wait — it is cleared when the job starts.
|
|
61
64
|
|
|
62
65
|
return queue.add(() => {
|
|
63
66
|
clearTimeout(timer);
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* windowState — read a page's framework state straight off `window`, after
|
|
3
|
+
* its JavaScript ran (extract_embedded_state's escalated path, plan Phase 3.2).
|
|
4
|
+
*
|
|
5
|
+
* The served HTML is not always where the state ends up: Nuxt 2 ships
|
|
6
|
+
* __NUXT__ as an IIFE the HTML parser cannot evaluate, and YouTube's
|
|
7
|
+
* ytInitialData is assigned by a script. Inside the stealth render those are
|
|
8
|
+
* plain globals, so they are read as the page itself sees them.
|
|
9
|
+
*
|
|
10
|
+
* Engines: on Chromium page.evaluate runs in the page's main world, where the
|
|
11
|
+
* globals live. On camoufox it runs in an isolated world that cannot see them
|
|
12
|
+
* (window.__NEXT_DATA__ is undefined there), but Firefox's Xray waiver,
|
|
13
|
+
* `window.wrappedJSObject`, reaches the page's own window from that world
|
|
14
|
+
* without evaluating anything in the page's — measured against camoufox
|
|
15
|
+
* 0.1.19, 2026-09-29. So the read works on both engines and the page never
|
|
16
|
+
* sees a script of ours run in its world.
|
|
17
|
+
*
|
|
18
|
+
* Each value is serialised INSIDE the page with its own try/catch: a cyclic or
|
|
19
|
+
* otherwise unserialisable global is skipped with a warning instead of
|
|
20
|
+
* failing the read of the others.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** The globals read, in this order. Only the ones present come back. */
|
|
24
|
+
export const WINDOW_STATE_GLOBALS = Object.freeze([
|
|
25
|
+
'__NEXT_DATA__',
|
|
26
|
+
'__NUXT__',
|
|
27
|
+
'__remixContext',
|
|
28
|
+
'ytInitialData',
|
|
29
|
+
'ytInitialPlayerResponse',
|
|
30
|
+
'__INITIAL_STATE__',
|
|
31
|
+
'__PRELOADED_STATE__',
|
|
32
|
+
'__APOLLO_STATE__',
|
|
33
|
+
'__TGT_DATA__',
|
|
34
|
+
'__PWS_DATA__'
|
|
35
|
+
]);
|
|
36
|
+
|
|
37
|
+
/** Said wherever these values are reported: they are not what the server sent. */
|
|
38
|
+
export const WINDOW_STATE_NOTE = 'read from window after JavaScript ran, not from the served HTML';
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Runs in the page. `{ json }` for a global that serialised, `{ error }` for
|
|
42
|
+
* one that did not; absent (null/undefined) globals are left out.
|
|
43
|
+
* @param {string[]} names
|
|
44
|
+
*/
|
|
45
|
+
export function serialiseWindowGlobals(names) {
|
|
46
|
+
const target = window.wrappedJSObject || window;
|
|
47
|
+
const out = {};
|
|
48
|
+
for (const name of names) {
|
|
49
|
+
try {
|
|
50
|
+
const value = target[name];
|
|
51
|
+
if (value === undefined || value === null) continue;
|
|
52
|
+
const json = JSON.stringify(value);
|
|
53
|
+
out[name] = typeof json === 'string' ? { json } : { error: 'it has no JSON form' };
|
|
54
|
+
} catch (error) {
|
|
55
|
+
out[name] = { error: String((error && error.message) || error) };
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* @param {{ evaluate: Function }} page a Playwright page
|
|
63
|
+
* @returns {Promise<{ state: Record<string, unknown>, warnings: string[] }>}
|
|
64
|
+
*/
|
|
65
|
+
export async function readWindowState(page) {
|
|
66
|
+
const warnings = [];
|
|
67
|
+
let raw;
|
|
68
|
+
try {
|
|
69
|
+
raw = await page.evaluate(serialiseWindowGlobals, [...WINDOW_STATE_GLOBALS]);
|
|
70
|
+
} catch (error) {
|
|
71
|
+
warnings.push(`window_state could not be read: ${String(error.message).split('\n')[0]}`);
|
|
72
|
+
return { state: {}, warnings };
|
|
73
|
+
}
|
|
74
|
+
const state = {};
|
|
75
|
+
for (const [name, entry] of Object.entries(raw || {})) {
|
|
76
|
+
if (entry && typeof entry.json === 'string') {
|
|
77
|
+
try {
|
|
78
|
+
state[name] = JSON.parse(entry.json);
|
|
79
|
+
continue;
|
|
80
|
+
} catch (error) {
|
|
81
|
+
warnings.push(`window_state: ${name} skipped - its JSON did not parse (${error.message})`);
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
warnings.push(`window_state: ${name} skipped - not serialisable (${entry?.error ?? 'unknown reason'})`);
|
|
86
|
+
}
|
|
87
|
+
return { state, warnings };
|
|
88
|
+
}
|
|
@@ -8,9 +8,10 @@ import { LinkAnalyzer } from '../analysis/LinkAnalyzer.js';
|
|
|
8
8
|
import { normalizeUrl, extractLinks, isValidUrl } from '../../utils/urlNormalizer.js';
|
|
9
9
|
import { Logger } from '../../utils/Logger.js';
|
|
10
10
|
import { safeFetch } from '../../utils/ssrfGuard.js';
|
|
11
|
-
import { robotsPreflight, RobotsDisallowedError } from '../../utils/robotsGate.js';
|
|
11
|
+
import { robotsPreflight, redirectGate, RobotsDisallowedError } from '../../utils/robotsGate.js';
|
|
12
12
|
import { throttleHost } from '../../utils/hostRateLimiter.js';
|
|
13
13
|
import { CRAWLFORGE_USER_AGENT, identityHeaders } from '../../utils/fetchIdentity.js';
|
|
14
|
+
import { signRequestHeaders } from '../../utils/webBotAuth.js';
|
|
14
15
|
import { pageTitle } from '../../utils/pageTitle.js';
|
|
15
16
|
import { extractMainContent, isThinMainContent } from '../../tools/scrape/_mainContent.js';
|
|
16
17
|
|
|
@@ -337,7 +338,10 @@ export class BFSCrawler {
|
|
|
337
338
|
'Accept-Language': 'en-US,en;q=0.5',
|
|
338
339
|
'Accept-Encoding': 'gzip, deflate',
|
|
339
340
|
'Connection': 'keep-alive',
|
|
340
|
-
'Upgrade-Insecure-Requests': '1'
|
|
341
|
+
'Upgrade-Insecure-Requests': '1',
|
|
342
|
+
// Web Bot Auth, as on every other fetching tool. crawl_deep pages went
|
|
343
|
+
// out with the identity alone: a claim with no proof.
|
|
344
|
+
...signRequestHeaders(url)
|
|
341
345
|
};
|
|
342
346
|
|
|
343
347
|
let headers = { ...defaultHeaders, ...domainRules.customHeaders };
|
|
@@ -357,7 +361,12 @@ export class BFSCrawler {
|
|
|
357
361
|
|
|
358
362
|
const response = await safeFetch(url, {
|
|
359
363
|
signal: controller.signal,
|
|
360
|
-
headers
|
|
364
|
+
headers,
|
|
365
|
+
onRedirect: redirectGate(url, {
|
|
366
|
+
respectRobots: this.respectRobots,
|
|
367
|
+
userAgent: this.userAgent,
|
|
368
|
+
tool: 'crawl_deep'
|
|
369
|
+
})
|
|
361
370
|
});
|
|
362
371
|
|
|
363
372
|
// Capture any cookies the server sets during the crawl
|
|
@@ -9,7 +9,7 @@ import { z } from 'zod';
|
|
|
9
9
|
import StealthBrowserManager from '../StealthBrowserManager.js';
|
|
10
10
|
import HumanBehaviorSimulator from '../../utils/HumanBehaviorSimulator.js';
|
|
11
11
|
import LocalizationManager from '../LocalizationManager.js';
|
|
12
|
-
import { safeGoto } from '../../utils/ssrfGuard.js';
|
|
12
|
+
import { safeGoto, assertNavigationAllowed } from '../../utils/ssrfGuard.js';
|
|
13
13
|
|
|
14
14
|
const BrowserProcessorSchema = z.object({
|
|
15
15
|
url: z.string().url(),
|
|
@@ -156,9 +156,12 @@ export class BrowserProcessor {
|
|
|
156
156
|
* @param {Object} params - Processing parameters
|
|
157
157
|
* @param {string} params.url - URL to process
|
|
158
158
|
* @param {Object} params.options - Browser options
|
|
159
|
+
* @param {{ onRedirect?: (to: string) => Promise<void> }} [gate] - what the
|
|
160
|
+
* calling tool's `preflightFetch` returned, so a redirect is gated the way
|
|
161
|
+
* the URL was. A second argument because the schema strips it from `params`.
|
|
159
162
|
* @returns {Promise<Object>} - Processing result with rendered content
|
|
160
163
|
*/
|
|
161
|
-
async processURL(params) {
|
|
164
|
+
async processURL(params, gate) {
|
|
162
165
|
const startTime = Date.now();
|
|
163
166
|
|
|
164
167
|
try {
|
|
@@ -178,7 +181,7 @@ export class BrowserProcessor {
|
|
|
178
181
|
|
|
179
182
|
try {
|
|
180
183
|
// Navigate and wait for content
|
|
181
|
-
const navigationResult = await this.navigateAndWait(page, url, processingOptions);
|
|
184
|
+
const navigationResult = await this.navigateAndWait(page, url, processingOptions, gate?.onRedirect);
|
|
182
185
|
|
|
183
186
|
// Extract content and metadata
|
|
184
187
|
const contentResult = await this.extractContent(page, processingOptions);
|
|
@@ -370,6 +373,11 @@ export class BrowserProcessor {
|
|
|
370
373
|
// createStealthContext re-validates the config it is given — so a context
|
|
371
374
|
// asked for without it would relaunch on the default engine underneath.
|
|
372
375
|
const engine = options.stealthMode.engine || 'chromium';
|
|
376
|
+
// The caller's own proxies (scrape_with_actions browserOptions.proxyRotation),
|
|
377
|
+
// to both calls for the same reason as the engine: camoufox takes its proxy
|
|
378
|
+
// at launch, Chromium per context. Absent unless the caller sent one, so
|
|
379
|
+
// CRAWLFORGE_STEALTH_PROXIES still applies when nobody did.
|
|
380
|
+
const proxy = options.proxyRotation ? { proxyRotation: options.proxyRotation } : {};
|
|
373
381
|
|
|
374
382
|
// Launch stealth browser
|
|
375
383
|
await this.stealthManager.launchStealthBrowser({
|
|
@@ -378,12 +386,14 @@ export class BrowserProcessor {
|
|
|
378
386
|
randomizeFingerprint: options.stealthMode.randomizeFingerprint,
|
|
379
387
|
hideWebDriver: options.stealthMode.hideWebDriver,
|
|
380
388
|
blockWebRTC: options.stealthMode.blockWebRTC,
|
|
381
|
-
customUserAgent: options.stealthMode.customUserAgent || options.userAgent
|
|
389
|
+
customUserAgent: options.stealthMode.customUserAgent || options.userAgent,
|
|
390
|
+
...proxy
|
|
382
391
|
});
|
|
383
392
|
|
|
384
393
|
// Create stealth context
|
|
385
394
|
const { context, contextId } = await this.stealthManager.createStealthContext({
|
|
386
395
|
engine,
|
|
396
|
+
...proxy,
|
|
387
397
|
level: options.stealthMode.level,
|
|
388
398
|
customViewport: {
|
|
389
399
|
width: options.viewportWidth || 1280,
|
|
@@ -635,18 +645,23 @@ export class BrowserProcessor {
|
|
|
635
645
|
* @param {Page} page - Playwright page
|
|
636
646
|
* @param {string} url - URL to navigate to
|
|
637
647
|
* @param {Object} options - Navigation options
|
|
648
|
+
* @param {(to: string) => Promise<void>} [onRedirect] - the calling tool's
|
|
649
|
+
* redirect gate (robotsGate.js `redirectGate`)
|
|
638
650
|
* @returns {Promise<Object>} - Navigation result
|
|
639
651
|
*/
|
|
640
|
-
async navigateAndWait(page, url, options) {
|
|
652
|
+
async navigateAndWait(page, url, options, onRedirect) {
|
|
641
653
|
const startTime = Date.now();
|
|
642
654
|
|
|
643
655
|
// Navigate to URL. safeGoto adds the SSRF/DNS-rebinding guard on top of the
|
|
644
|
-
// robots/blocklist preflight the calling tool already ran, and re-checks
|
|
645
|
-
//
|
|
656
|
+
// robots/blocklist preflight the calling tool already ran, and re-checks
|
|
657
|
+
// where the navigation went, so a redirect can't carry us into a
|
|
658
|
+
// private/metadata range or past the gate the URL itself went through.
|
|
646
659
|
await safeGoto(page, url, {
|
|
647
660
|
waitUntil: 'domcontentloaded',
|
|
648
|
-
timeout: 30000
|
|
661
|
+
timeout: 30000,
|
|
662
|
+
onRedirect
|
|
649
663
|
});
|
|
664
|
+
const landedUrl = page.url();
|
|
650
665
|
|
|
651
666
|
// Wait for specific selector if provided
|
|
652
667
|
if (options.waitForSelector) {
|
|
@@ -687,6 +702,10 @@ export class BrowserProcessor {
|
|
|
687
702
|
}
|
|
688
703
|
}
|
|
689
704
|
|
|
705
|
+
// The waits above are time in which a client-side redirect can move the
|
|
706
|
+
// page on; the document about to be read is the one that has to pass.
|
|
707
|
+
await assertNavigationAllowed(page, landedUrl, null, onRedirect);
|
|
708
|
+
|
|
690
709
|
return {
|
|
691
710
|
navigationTime: Date.now() - startTime
|
|
692
711
|
};
|
|
@@ -10,6 +10,7 @@ import path from 'path';
|
|
|
10
10
|
import { safeFetch } from '../../utils/ssrfGuard.js';
|
|
11
11
|
import { config } from '../../constants/config.js';
|
|
12
12
|
import { identityHeaders } from '../../utils/fetchIdentity.js';
|
|
13
|
+
import { signRequestHeaders } from '../../utils/webBotAuth.js';
|
|
13
14
|
|
|
14
15
|
const PDFProcessorSchema = z.object({
|
|
15
16
|
// A Buffer is the 'buffer' source: process_document hands over a body it
|
|
@@ -85,9 +86,11 @@ export class PDFProcessor {
|
|
|
85
86
|
* @param {string} params.source - PDF source (URL, file path, or buffer)
|
|
86
87
|
* @param {string} params.sourceType - Type of source ('url', 'file', 'buffer')
|
|
87
88
|
* @param {Object} params.options - Processing options
|
|
89
|
+
* @param {{ headers: Record<string,string>, onRedirect: Function }} [gate] -
|
|
90
|
+
* what `preflightFetch` returned for a URL source
|
|
88
91
|
* @returns {Promise<Object>} - Processing result with text and metadata
|
|
89
92
|
*/
|
|
90
|
-
async processPDF(params) {
|
|
93
|
+
async processPDF(params, gate) {
|
|
91
94
|
const startTime = Date.now();
|
|
92
95
|
|
|
93
96
|
try {
|
|
@@ -106,7 +109,7 @@ export class PDFProcessor {
|
|
|
106
109
|
// Get PDF buffer based on source type
|
|
107
110
|
let pdfBuffer;
|
|
108
111
|
try {
|
|
109
|
-
pdfBuffer = await this.getPDFBuffer(source, sourceType);
|
|
112
|
+
pdfBuffer = await this.getPDFBuffer(source, sourceType, gate);
|
|
110
113
|
} catch (error) {
|
|
111
114
|
result.error = `Failed to load PDF: ${error.message}`;
|
|
112
115
|
result.processingTime = Date.now() - startTime;
|
|
@@ -214,12 +217,13 @@ export class PDFProcessor {
|
|
|
214
217
|
* Get PDF buffer from various sources
|
|
215
218
|
* @param {string} source - PDF source
|
|
216
219
|
* @param {string} sourceType - Source type
|
|
220
|
+
* @param {Object} [gate] - see processPDF
|
|
217
221
|
* @returns {Promise<Buffer>} - PDF buffer
|
|
218
222
|
*/
|
|
219
|
-
async getPDFBuffer(source, sourceType) {
|
|
223
|
+
async getPDFBuffer(source, sourceType, gate) {
|
|
220
224
|
switch (sourceType) {
|
|
221
225
|
case 'url':
|
|
222
|
-
return await this.downloadPDFFromURL(source);
|
|
226
|
+
return await this.downloadPDFFromURL(source, gate);
|
|
223
227
|
case 'file':
|
|
224
228
|
return await this.readPDFFromFile(source);
|
|
225
229
|
case 'buffer':
|
|
@@ -232,15 +236,20 @@ export class PDFProcessor {
|
|
|
232
236
|
/**
|
|
233
237
|
* Download PDF from URL
|
|
234
238
|
* @param {string} url - PDF URL
|
|
239
|
+
* @param {Object} [gate] - see processPDF
|
|
235
240
|
* @returns {Promise<Buffer>} - PDF buffer
|
|
236
241
|
*/
|
|
237
|
-
async downloadPDFFromURL(url) {
|
|
242
|
+
async downloadPDFFromURL(url, gate) {
|
|
238
243
|
try {
|
|
239
244
|
// `timeout` is not a fetch init option — undici/Node fetch silently
|
|
240
245
|
// ignores unknown properties, so only `signal` actually enforces a
|
|
241
246
|
// deadline here.
|
|
247
|
+
// The gate's headers are the identity the URL was cleared under plus the
|
|
248
|
+
// Web Bot Auth signature; this download used to discard them and go out
|
|
249
|
+
// unsigned. With no gate, sign as ourselves.
|
|
242
250
|
const response = await safeFetch(url, {
|
|
243
|
-
headers: identityHeaders(),
|
|
251
|
+
headers: gate?.headers ?? { ...identityHeaders(), ...signRequestHeaders(url) },
|
|
252
|
+
onRedirect: gate?.onRedirect,
|
|
244
253
|
signal: AbortSignal.timeout(30000)
|
|
245
254
|
});
|
|
246
255
|
|
|
@@ -44,8 +44,8 @@ export const FALLBACK_HINTS = Object.freeze({
|
|
|
44
44
|
});
|
|
45
45
|
|
|
46
46
|
/**
|
|
47
|
-
* The second-stage hint (Phase 3, 3.4). A `scrape`
|
|
48
|
-
* `escalated: true` has ALREADY run the stealth browser on this URL, so the
|
|
47
|
+
* The second-stage hint (Phase 3, 3.4). A `scrape` or `extract_embedded_state`
|
|
48
|
+
* that came back `escalated: true` has ALREADY run the stealth browser on this URL, so the
|
|
49
49
|
* ordinary hint would send the model back to a tool that has just failed.
|
|
50
50
|
* What is left is a region-specific block, which `localization` can change,
|
|
51
51
|
* or a block that is final: an Akamai-style TLS-level wall is beaten with
|
|
@@ -56,9 +56,12 @@ export const SCRAPE_ESCALATED_HINT =
|
|
|
56
56
|
'If the block is regional, set a country with localization and try once more; otherwise the block is final - ' +
|
|
57
57
|
'a TLS-level wall needs residential proxies, which CrawlForge does not offer. Get the content from another source.';
|
|
58
58
|
|
|
59
|
-
/** The
|
|
59
|
+
/** The tools whose escalate:true runs the stealth stage itself (Phase 3; extract_embedded_state since plan Phase 3.2). */
|
|
60
|
+
const ESCALATING_TOOLS = new Set(['scrape', 'extract_embedded_state']);
|
|
61
|
+
|
|
62
|
+
/** The hint for this result: the tool's, unless the call already escalated. */
|
|
60
63
|
function hintFor(toolName, parsed) {
|
|
61
|
-
if (toolName
|
|
64
|
+
if (ESCALATING_TOOLS.has(toolName) && parsed?.escalated === true) return SCRAPE_ESCALATED_HINT;
|
|
62
65
|
return FALLBACK_HINTS[toolName];
|
|
63
66
|
}
|
|
64
67
|
|