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
|
@@ -7,11 +7,12 @@ import { z } from 'zod';
|
|
|
7
7
|
import BrowserProcessor from './processing/BrowserProcessor.js';
|
|
8
8
|
import { EventEmitter } from 'events';
|
|
9
9
|
import { createHash } from 'node:crypto';
|
|
10
|
-
import { assertUrlAllowed } from '../utils/ssrfGuard.js';
|
|
11
|
-
import { browserPreflight } from '../utils/robotsGate.js';
|
|
10
|
+
import { assertUrlAllowed, assertNavigationAllowed } from '../utils/ssrfGuard.js';
|
|
11
|
+
import { browserPreflight, redirectGate, pageMoveGate, gateRefusalCode } from '../utils/robotsGate.js';
|
|
12
12
|
import { isRef, resolveRef, captureSnapshot } from './browser/snapshot.js';
|
|
13
13
|
import { settlePage } from './browser/settle.js';
|
|
14
14
|
import { handleConsent } from './browser/consent.js';
|
|
15
|
+
import { stealthDocumentVerdict } from '../utils/stealthVerdict.js';
|
|
15
16
|
|
|
16
17
|
// executeJavaScript hardening limits (only relevant when the deploy-time flag
|
|
17
18
|
// ALLOW_JAVASCRIPT_EXECUTION=true is set; JS execution stays off by default).
|
|
@@ -31,6 +32,13 @@ const ACTION_TIMEOUT_GRACE_MS = 2000;
|
|
|
31
32
|
// to work cost several times its stated timeout.
|
|
32
33
|
const RECOVERY_TIMEOUT_MS = 3000;
|
|
33
34
|
|
|
35
|
+
// How long a navigation whose document reads as a wall is watched for the wall
|
|
36
|
+
// replacing itself before it is reported, re-read every NAVIGATION_WALL_POLL_MS.
|
|
37
|
+
// AWS WAF's interstitial reloads into amazon.com's homepage about 0.4 s after
|
|
38
|
+
// domcontentloaded (measured 2026-10-03); nothing here acts on the page.
|
|
39
|
+
const NAVIGATION_WALL_GRACE_MS = 3000;
|
|
40
|
+
const NAVIGATION_WALL_POLL_MS = 500;
|
|
41
|
+
|
|
34
42
|
// The only states locator.waitFor()/page.waitForSelector() accept. The rest of
|
|
35
43
|
// the wait-action enum (enabled/disabled/stable) are ElementHandle states and
|
|
36
44
|
// have to go through waitForElementState instead — passing them here is
|
|
@@ -312,17 +320,20 @@ export class ActionExecutor extends EventEmitter {
|
|
|
312
320
|
executionContext.consent = await handleConsent(page, browserOptions.consent);
|
|
313
321
|
}
|
|
314
322
|
|
|
323
|
+
// navigations[0] is the initial load; each `navigate` action appends
|
|
324
|
+
// its own (executeNavigateAction).
|
|
325
|
+
executionContext.navigations = [await this.checkNavigation(page, url, browserOptions)];
|
|
326
|
+
|
|
315
327
|
// Execute chain with potential retries
|
|
316
328
|
chainResult = await this.executeChainWithRetries(executionContext);
|
|
317
329
|
|
|
318
330
|
// Capture the LIVE post-action page state before the page is closed,
|
|
319
331
|
// so callers can extract final content reflecting all actions
|
|
320
332
|
// (instead of re-fetching the original URL).
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
executionContext.
|
|
324
|
-
|
|
325
|
-
this.log('warn', 'Failed to capture final page content: ' + captureErr.message);
|
|
333
|
+
const finalState = await this.captureState(page, browserOptions, 'final page content');
|
|
334
|
+
if (finalState) {
|
|
335
|
+
executionContext.finalHtml = finalState.html;
|
|
336
|
+
executionContext.finalUrl = finalState.url;
|
|
326
337
|
}
|
|
327
338
|
|
|
328
339
|
this.stats.successfulChains++;
|
|
@@ -336,6 +347,9 @@ export class ActionExecutor extends EventEmitter {
|
|
|
336
347
|
// Capture error screenshot if enabled
|
|
337
348
|
if ((validatedChain.screenshotOnError ?? this.enableScreenshotOnError) && page) {
|
|
338
349
|
try {
|
|
350
|
+
// Not of a page the gate has not passed: the chain may have failed
|
|
351
|
+
// for another reason after the page moved.
|
|
352
|
+
await this.assertPageAllowed(page, browserOptions);
|
|
339
353
|
const errorScreenshot = await this.captureScreenshot(page, {
|
|
340
354
|
fullPage: true,
|
|
341
355
|
description: 'Error screenshot'
|
|
@@ -417,6 +431,7 @@ export class ActionExecutor extends EventEmitter {
|
|
|
417
431
|
finalUrl: executionContext.finalUrl || url,
|
|
418
432
|
finalHtml: executionContext.finalHtml,
|
|
419
433
|
navigationStatus: executionContext.page?.__crawlforgeNavigation?.status ?? null,
|
|
434
|
+
navigations: executionContext.navigations || [],
|
|
420
435
|
consent: executionContext.consent,
|
|
421
436
|
executionTime: Date.now() - startTime,
|
|
422
437
|
results: executionContext.results,
|
|
@@ -444,6 +459,7 @@ export class ActionExecutor extends EventEmitter {
|
|
|
444
459
|
// results, the error screenshot, and any intermediate-state
|
|
445
460
|
// captures) instead of discarding them.
|
|
446
461
|
results: executionContext?.results || [],
|
|
462
|
+
navigations: executionContext?.navigations || [],
|
|
447
463
|
attempt: executionContext?.attempt ?? 0,
|
|
448
464
|
attempts: executionContext?.attempts || [],
|
|
449
465
|
screenshots: executionContext?.screenshots || [],
|
|
@@ -473,7 +489,14 @@ export class ActionExecutor extends EventEmitter {
|
|
|
473
489
|
// (form half-filled, menu open, possibly a different URL) is not a
|
|
474
490
|
// retry. Reload the starting URL so every attempt begins where the
|
|
475
491
|
// first one did.
|
|
476
|
-
await this.navigateToUrl(page, executionContext.url
|
|
492
|
+
await this.navigateToUrl(page, executionContext.url, {
|
|
493
|
+
browserOptions: executionContext.browserOptions
|
|
494
|
+
});
|
|
495
|
+
// Only the last attempt's navigations are reported, so the reload
|
|
496
|
+
// that starts this one is navigations[0].
|
|
497
|
+
executionContext.navigations = [
|
|
498
|
+
await this.checkNavigation(page, executionContext.url, executionContext.browserOptions)
|
|
499
|
+
];
|
|
477
500
|
}
|
|
478
501
|
|
|
479
502
|
// Execute actions in sequence
|
|
@@ -486,7 +509,7 @@ export class ActionExecutor extends EventEmitter {
|
|
|
486
509
|
...chain.actions[i],
|
|
487
510
|
timeout: Math.min(chain.actions[i].timeout || chain.timeout, chain.timeout)
|
|
488
511
|
};
|
|
489
|
-
const actionResult = await this.
|
|
512
|
+
const actionResult = await this.executeGatedAction(page, action, executionContext);
|
|
490
513
|
|
|
491
514
|
executionContext.results.push(actionResult);
|
|
492
515
|
this.stats.totalActions++;
|
|
@@ -510,7 +533,7 @@ export class ActionExecutor extends EventEmitter {
|
|
|
510
533
|
|
|
511
534
|
// Handle action failure
|
|
512
535
|
if (!action.continueOnError && !chain.continueOnError) {
|
|
513
|
-
throw new Error('Action failed: ' + actionResult.error);
|
|
536
|
+
throw Object.assign(new Error('Action failed: ' + actionResult.error), { code: actionResult.errorCode });
|
|
514
537
|
}
|
|
515
538
|
}
|
|
516
539
|
|
|
@@ -519,18 +542,16 @@ export class ActionExecutor extends EventEmitter {
|
|
|
519
542
|
// the ALLOW_JAVASCRIPT_EXECUTION flag and doesn't add phantom
|
|
520
543
|
// actions to the chain's failure/success counts.
|
|
521
544
|
if (action.captureAfter) {
|
|
522
|
-
|
|
523
|
-
|
|
545
|
+
const captured = await this.captureState(page, executionContext.browserOptions, 'intermediate state');
|
|
546
|
+
if (captured) {
|
|
524
547
|
executionContext.capturedStates = executionContext.capturedStates || [];
|
|
525
548
|
executionContext.capturedStates.push({
|
|
526
549
|
afterActionIndex: i,
|
|
527
550
|
afterActionId: actionResult.id,
|
|
528
|
-
url:
|
|
529
|
-
html:
|
|
551
|
+
url: captured.url,
|
|
552
|
+
html: captured.html,
|
|
530
553
|
timestamp: Date.now()
|
|
531
554
|
});
|
|
532
|
-
} catch (captureErr) {
|
|
533
|
-
this.log('warn', 'Failed to capture intermediate state: ' + captureErr.message);
|
|
534
555
|
}
|
|
535
556
|
}
|
|
536
557
|
|
|
@@ -551,7 +572,12 @@ export class ActionExecutor extends EventEmitter {
|
|
|
551
572
|
attempt: attempt + 1, success: false, error: error.message, results: [...executionContext.results]
|
|
552
573
|
});
|
|
553
574
|
this.log('warn', 'Chain execution attempt ' + (attempt + 1) + ' failed: ' + error.message);
|
|
554
|
-
|
|
575
|
+
|
|
576
|
+
// A refusal is the gate's answer, not a fault. Replaying the chain
|
|
577
|
+
// asks the same question again, after sending the site every request
|
|
578
|
+
// that led up to it a second time.
|
|
579
|
+
if (gateRefusalCode(error)) break;
|
|
580
|
+
|
|
555
581
|
if (attempt < chain.retryChain) {
|
|
556
582
|
// Wait before retry
|
|
557
583
|
await this.delay(1000 * Math.pow(2, attempt));
|
|
@@ -577,8 +603,12 @@ export class ActionExecutor extends EventEmitter {
|
|
|
577
603
|
*
|
|
578
604
|
* Gating comes for free and must not be duplicated by callers: a `navigate`
|
|
579
605
|
* action goes through executeNavigateAction, which re-runs the SSRF guard and
|
|
580
|
-
* the blocklist/robots gate on every hop (see the comment at its definition)
|
|
581
|
-
*
|
|
606
|
+
* the blocklist/robots gate on every hop (see the comment at its definition),
|
|
607
|
+
* and every action is run through executeGatedAction, which holds the page to
|
|
608
|
+
* the same gate wherever a click or a script has taken it. A refusal there
|
|
609
|
+
* throws rather than failing one action: the page has been emptied, and
|
|
610
|
+
* nothing after it could run. `browserOptions.respectRobots` is what reaches
|
|
611
|
+
* both gates.
|
|
582
612
|
*
|
|
583
613
|
* No `finalHtml` here, unlike the chain: the page is still open afterwards, so
|
|
584
614
|
* content is read from it when it is asked for rather than on every call.
|
|
@@ -614,7 +644,7 @@ export class ActionExecutor extends EventEmitter {
|
|
|
614
644
|
// per-call timeout is applied as the default for actions that set none.
|
|
615
645
|
const action = timeout && !actions[i].timeout ? { ...actions[i], timeout } : actions[i];
|
|
616
646
|
|
|
617
|
-
const actionResult = await this.
|
|
647
|
+
const actionResult = await this.executeGatedAction(page, action, executionContext);
|
|
618
648
|
executionContext.results.push(actionResult);
|
|
619
649
|
this.stats.totalActions++;
|
|
620
650
|
|
|
@@ -638,17 +668,15 @@ export class ActionExecutor extends EventEmitter {
|
|
|
638
668
|
}
|
|
639
669
|
|
|
640
670
|
if (action.captureAfter) {
|
|
641
|
-
|
|
642
|
-
|
|
671
|
+
const captured = await this.captureState(page, browserOptions, 'intermediate state');
|
|
672
|
+
if (captured) {
|
|
643
673
|
executionContext.capturedStates.push({
|
|
644
674
|
afterActionIndex: i,
|
|
645
675
|
afterActionId: actionResult.id,
|
|
646
|
-
url:
|
|
647
|
-
html:
|
|
676
|
+
url: captured.url,
|
|
677
|
+
html: captured.html,
|
|
648
678
|
timestamp: Date.now()
|
|
649
679
|
});
|
|
650
|
-
} catch (captureErr) {
|
|
651
|
-
this.log('warn', 'Failed to capture intermediate state: ' + captureErr.message);
|
|
652
680
|
}
|
|
653
681
|
}
|
|
654
682
|
|
|
@@ -679,6 +707,88 @@ export class ActionExecutor extends EventEmitter {
|
|
|
679
707
|
};
|
|
680
708
|
}
|
|
681
709
|
|
|
710
|
+
/**
|
|
711
|
+
* Hold the page to the gate wherever it stands now.
|
|
712
|
+
*
|
|
713
|
+
* navigateToUrl gates a load and its redirects. After that a page moves
|
|
714
|
+
* without being sent anywhere: a click follows a link, a form posts, a
|
|
715
|
+
* script redirects, a client-side router rewrites the address. Any URL the
|
|
716
|
+
* page has reached since it was last checked gets what a `navigate` to it
|
|
717
|
+
* would: the SSRF guard, then the blocklist/robots gate with this call's
|
|
718
|
+
* `respectRobots`. A refusal leaves the page empty and throws.
|
|
719
|
+
*
|
|
720
|
+
* Cheap when nothing moved (one string comparison), so it is called on both
|
|
721
|
+
* sides of everything that touches the page.
|
|
722
|
+
* @param {Page} page - Playwright page
|
|
723
|
+
* @param {Object} [browserOptions] - Browser options (`respectRobots` override)
|
|
724
|
+
* @returns {Promise<void>}
|
|
725
|
+
* @throws {Error} SSRF_BLOCKED, BlockedHostError or RobotsDisallowedError
|
|
726
|
+
*/
|
|
727
|
+
async assertPageAllowed(page, browserOptions = {}) {
|
|
728
|
+
const gatedUrl = page.__crawlforgeGatedUrl;
|
|
729
|
+
if (page.url() === gatedUrl) return;
|
|
730
|
+
page.__crawlforgeGatedUrl = await assertNavigationAllowed(page, gatedUrl, null, pageMoveGate(gatedUrl, {
|
|
731
|
+
respectRobots: browserOptions?.respectRobots,
|
|
732
|
+
tool: browserOptions?.tool || 'scrape_with_actions'
|
|
733
|
+
}));
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
/**
|
|
737
|
+
* Read from the page with the gate held on both sides of the read, so what
|
|
738
|
+
* comes back is from a document that passed it: the check after the read
|
|
739
|
+
* discards anything read while the page was moving somewhere it may not be.
|
|
740
|
+
* @param {Page} page - Playwright page
|
|
741
|
+
* @param {Object} [browserOptions] - Browser options (`respectRobots` override)
|
|
742
|
+
* @param {() => Promise<T>} read - the read to make
|
|
743
|
+
* @returns {Promise<T>}
|
|
744
|
+
* @template T
|
|
745
|
+
*/
|
|
746
|
+
async readGated(page, browserOptions, read) {
|
|
747
|
+
await this.assertPageAllowed(page, browserOptions);
|
|
748
|
+
const value = await read();
|
|
749
|
+
await this.assertPageAllowed(page, browserOptions);
|
|
750
|
+
return value;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
/**
|
|
754
|
+
* The page's URL and HTML, read natively (no in-page JS execution, so it
|
|
755
|
+
* works regardless of ALLOW_JAVASCRIPT_EXECUTION). A page that cannot be
|
|
756
|
+
* read yields null and a warning; a page the gate refuses throws.
|
|
757
|
+
* @param {Page} page - Playwright page
|
|
758
|
+
* @param {Object} [browserOptions] - Browser options (`respectRobots` override)
|
|
759
|
+
* @param {string} what - names the capture in the warning
|
|
760
|
+
* @returns {Promise<{ url: string, html: string }|null>}
|
|
761
|
+
*/
|
|
762
|
+
async captureState(page, browserOptions, what) {
|
|
763
|
+
return await this.readGated(page, browserOptions, async () => {
|
|
764
|
+
try {
|
|
765
|
+
return { html: await page.content(), url: page.url() };
|
|
766
|
+
} catch (captureErr) {
|
|
767
|
+
this.log('warn', 'Failed to capture ' + what + ': ' + captureErr.message);
|
|
768
|
+
return null;
|
|
769
|
+
}
|
|
770
|
+
});
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
/**
|
|
774
|
+
* Run one action with the page held to the gate before it and after it.
|
|
775
|
+
* Before, because an action must not read or drive a page that moved while
|
|
776
|
+
* nothing was looking; after, because the action is what usually moves it,
|
|
777
|
+
* and its result is not kept if where it led is refused.
|
|
778
|
+
* @param {Page} page - Playwright page
|
|
779
|
+
* @param {Object} action - Action to execute
|
|
780
|
+
* @param {Object} executionContext - Execution context (`browserOptions` feeds the gate)
|
|
781
|
+
* @returns {Promise<Object>} Action result
|
|
782
|
+
*/
|
|
783
|
+
async executeGatedAction(page, action, executionContext) {
|
|
784
|
+
const browserOptions = executionContext?.browserOptions;
|
|
785
|
+
// A navigate leaves the page it is on and gates the URL it is sent to.
|
|
786
|
+
if (action.type !== 'navigate') await this.assertPageAllowed(page, browserOptions);
|
|
787
|
+
const actionResult = await this.executeActionInternal(page, action, executionContext);
|
|
788
|
+
await this.assertPageAllowed(page, browserOptions);
|
|
789
|
+
return actionResult;
|
|
790
|
+
}
|
|
791
|
+
|
|
682
792
|
/**
|
|
683
793
|
* Execute individual action (original internal method)
|
|
684
794
|
* @param {Page} page - Playwright page
|
|
@@ -753,11 +863,15 @@ export class ActionExecutor extends EventEmitter {
|
|
|
753
863
|
return actionResult;
|
|
754
864
|
|
|
755
865
|
} catch (error) {
|
|
866
|
+
const refusalCode = gateRefusalCode(error);
|
|
756
867
|
const actionResult = {
|
|
757
868
|
id: actionId,
|
|
758
869
|
type: action.type,
|
|
759
870
|
success: false,
|
|
760
871
|
error: error.message,
|
|
872
|
+
// Only when the gate refused (a `navigate` to a URL it would not
|
|
873
|
+
// load), so the chain can tell an answer from a fault.
|
|
874
|
+
...(refusalCode ? { errorCode: refusalCode } : {}),
|
|
761
875
|
executionTime: Date.now() - actionStartTime,
|
|
762
876
|
timestamp: Date.now(),
|
|
763
877
|
description: action.description
|
|
@@ -1239,7 +1353,8 @@ export class ActionExecutor extends EventEmitter {
|
|
|
1239
1353
|
|
|
1240
1354
|
await this.navigateToUrl(page, action.url, {
|
|
1241
1355
|
waitUntil: action.waitUntil,
|
|
1242
|
-
timeout
|
|
1356
|
+
timeout,
|
|
1357
|
+
browserOptions: executionContext?.browserOptions
|
|
1243
1358
|
});
|
|
1244
1359
|
page.__crawlforgeGateWarnings = gateWarnings;
|
|
1245
1360
|
|
|
@@ -1250,14 +1365,73 @@ export class ActionExecutor extends EventEmitter {
|
|
|
1250
1365
|
? await handleConsent(page, consentMode, { timeout: Math.min(2000, timeout - (Date.now() - startedAt)) })
|
|
1251
1366
|
: undefined;
|
|
1252
1367
|
|
|
1368
|
+
// Reporting only: the navigation happened, so the action succeeds whether
|
|
1369
|
+
// or not the document it reached is a wall. The wait for a wall to clear
|
|
1370
|
+
// gets what is left of the deadline, like consent above.
|
|
1371
|
+
const navigation = await this.checkNavigation(page, action.url, executionContext?.browserOptions, {
|
|
1372
|
+
graceMs: Math.min(NAVIGATION_WALL_GRACE_MS, timeout - (Date.now() - startedAt))
|
|
1373
|
+
});
|
|
1374
|
+
executionContext?.navigations?.push(navigation);
|
|
1375
|
+
|
|
1253
1376
|
return {
|
|
1254
1377
|
url: action.url,
|
|
1255
|
-
finalUrl:
|
|
1378
|
+
finalUrl: navigation.finalUrl,
|
|
1256
1379
|
waitUntil: action.waitUntil || 'domcontentloaded',
|
|
1380
|
+
httpStatus: navigation.httpStatus,
|
|
1381
|
+
...(navigation.blocked ? { blocked: navigation.blocked } : {}),
|
|
1257
1382
|
...(consent ? { consent } : {})
|
|
1258
1383
|
};
|
|
1259
1384
|
}
|
|
1260
1385
|
|
|
1386
|
+
/**
|
|
1387
|
+
* What the navigation that just finished reached: the page, or a wall. The
|
|
1388
|
+
* same verdict the chain's final document gets, run on this document with
|
|
1389
|
+
* the status of this navigation. A document that reads as a wall is re-read
|
|
1390
|
+
* for up to `graceMs` first, because some walls replace themselves (AWS WAF's
|
|
1391
|
+
* interstitial reloads into the page); one that cannot be read at all is
|
|
1392
|
+
* reported without a verdict rather than as a wall.
|
|
1393
|
+
* @param {Page} page - Playwright page, just navigated
|
|
1394
|
+
* @param {string} url - the URL the navigation was sent to
|
|
1395
|
+
* @param {Object} [browserOptions] - Browser options (`respectRobots` for the read's gate)
|
|
1396
|
+
* @param {{ graceMs?: number }} [options]
|
|
1397
|
+
* @returns {Promise<{ url: string, finalUrl: string, httpStatus: number|null, blocked?: { vendor: string, evidence: string } }>}
|
|
1398
|
+
*/
|
|
1399
|
+
async checkNavigation(page, url, browserOptions, { graceMs = NAVIGATION_WALL_GRACE_MS } = {}) {
|
|
1400
|
+
const httpStatus = page.__crawlforgeNavigation?.status ?? null;
|
|
1401
|
+
const read = () => this.readGated(page, browserOptions, async () => {
|
|
1402
|
+
try {
|
|
1403
|
+
const [title, html, text] = await Promise.all([
|
|
1404
|
+
page.title(),
|
|
1405
|
+
page.content(),
|
|
1406
|
+
page.evaluate(() => (document.body ? document.body.innerText : ''))
|
|
1407
|
+
]);
|
|
1408
|
+
return stealthDocumentVerdict(
|
|
1409
|
+
{ url: page.url(), title, html, text, status: httpStatus },
|
|
1410
|
+
{ allowEmpty: true, fetcher: browserOptions?.stealthMode?.enabled ? 'the stealth browser' : 'the browser' }
|
|
1411
|
+
);
|
|
1412
|
+
} catch {
|
|
1413
|
+
return null; // mid-navigation, or a page that cannot be read
|
|
1414
|
+
}
|
|
1415
|
+
});
|
|
1416
|
+
|
|
1417
|
+
let verdict = await read();
|
|
1418
|
+
const deadline = Date.now() + graceMs;
|
|
1419
|
+
while (verdict?.blocked && Date.now() + NAVIGATION_WALL_POLL_MS <= deadline) {
|
|
1420
|
+
await this.delay(NAVIGATION_WALL_POLL_MS);
|
|
1421
|
+
const next = await read();
|
|
1422
|
+
// A read that fails mid-reload says nothing yet; keep the wall until a
|
|
1423
|
+
// document answers.
|
|
1424
|
+
if (next) verdict = next;
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1427
|
+
return {
|
|
1428
|
+
url,
|
|
1429
|
+
finalUrl: page.url(),
|
|
1430
|
+
httpStatus,
|
|
1431
|
+
...(verdict?.blocked ? { blocked: verdict.blocked } : {})
|
|
1432
|
+
};
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1261
1435
|
/**
|
|
1262
1436
|
* Execute screenshot action
|
|
1263
1437
|
* @param {Page} page - Playwright page
|
|
@@ -1392,12 +1566,14 @@ export class ActionExecutor extends EventEmitter {
|
|
|
1392
1566
|
}
|
|
1393
1567
|
|
|
1394
1568
|
/**
|
|
1395
|
-
* Navigate an existing page to a URL under the SSRF checks every load needs
|
|
1396
|
-
*
|
|
1397
|
-
*
|
|
1569
|
+
* Navigate an existing page to a URL under the SSRF checks every load needs,
|
|
1570
|
+
* and put wherever it is redirected to through the same blocklist/robots
|
|
1571
|
+
* gate the URL itself passed (assertRobotsAllowed). Used for the initial
|
|
1572
|
+
* load, again before each chain retry, and by the `navigate` action.
|
|
1398
1573
|
* @param {Page} page - Playwright page
|
|
1399
1574
|
* @param {string} url - URL to navigate to
|
|
1400
|
-
* @param {{ waitUntil?: string, timeout?: number }} [options] -
|
|
1575
|
+
* @param {{ waitUntil?: string, timeout?: number, browserOptions?: Object }} [options] -
|
|
1576
|
+
* Navigation options; `browserOptions` carries `respectRobots` to the redirect gate
|
|
1401
1577
|
* @returns {Promise<void>}
|
|
1402
1578
|
*/
|
|
1403
1579
|
async navigateToUrl(page, url, options = {}) {
|
|
@@ -1410,12 +1586,16 @@ export class ActionExecutor extends EventEmitter {
|
|
|
1410
1586
|
timeout: options.timeout || 30000
|
|
1411
1587
|
});
|
|
1412
1588
|
|
|
1413
|
-
// Re-validate the
|
|
1414
|
-
//
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1589
|
+
// Re-validate where the navigation went: a redirect could have taken us
|
|
1590
|
+
// into a blocked range, onto a blocklisted host or into a path robots.txt
|
|
1591
|
+
// disallows, even though the original URL was fine. A refusal leaves the
|
|
1592
|
+
// page empty, so a session that keeps it cannot read the refused document.
|
|
1593
|
+
const landedUrl = await assertNavigationAllowed(page, url, response, redirectGate(url, {
|
|
1594
|
+
respectRobots: options.browserOptions?.respectRobots,
|
|
1595
|
+
tool: options.browserOptions?.tool || 'scrape_with_actions'
|
|
1596
|
+
}));
|
|
1597
|
+
// What assertPageAllowed compares the page against from here on.
|
|
1598
|
+
page.__crawlforgeGatedUrl = landedUrl;
|
|
1419
1599
|
|
|
1420
1600
|
// Remembered on the page so the chain result can report the status of
|
|
1421
1601
|
// the last navigation: tesla.com's Akamai denial ran a full action chain
|
|
@@ -1489,7 +1669,7 @@ export class ActionExecutor extends EventEmitter {
|
|
|
1489
1669
|
// Navigate to URL. The pre-flight above repeats inside navigateToUrl —
|
|
1490
1670
|
// that one is deliberately before page creation so a blocked URL never
|
|
1491
1671
|
// launches a browser (tests/unit/phase1-ssrf-paths.test.js pins it).
|
|
1492
|
-
await this.navigateToUrl(page, url);
|
|
1672
|
+
await this.navigateToUrl(page, url, { browserOptions });
|
|
1493
1673
|
|
|
1494
1674
|
// Handle CloudFlare challenges and reCAPTCHA if stealth mode is enabled
|
|
1495
1675
|
if (isStealth && this.browserProcessor.stealthManager) {
|
package/src/core/AuthManager.js
CHANGED
|
@@ -680,6 +680,13 @@ class AuthManager {
|
|
|
680
680
|
return costs.scrape + query + model + scrapeEscalationSurcharge(params?.escalate) + redaction;
|
|
681
681
|
}
|
|
682
682
|
|
|
683
|
+
// extract_embedded_state's escalate:true (plan Phase 3.2) is scrape's
|
|
684
|
+
// second stage on the same terms: the projection carries the stealth
|
|
685
|
+
// browser's 5, and the tool reports 2 when the plain fetch sufficed.
|
|
686
|
+
if (tool === 'extract_embedded_state') {
|
|
687
|
+
return costs.extract_embedded_state + scrapeEscalationSurcharge(params?.escalate) + redaction;
|
|
688
|
+
}
|
|
689
|
+
|
|
683
690
|
// search_web's batch form (5.1) runs one backend search per query, so it
|
|
684
691
|
// is priced as that many search_web calls. The count rule lives with the
|
|
685
692
|
// schema that declares `queries` (src/tools/search/batchSearch.js).
|
|
@@ -766,6 +773,11 @@ class AuthManager {
|
|
|
766
773
|
'json format may incur external LLM cost (billed by your provider).';
|
|
767
774
|
break;
|
|
768
775
|
}
|
|
776
|
+
case 'extract_embedded_state':
|
|
777
|
+
note = params?.escalate === true
|
|
778
|
+
? 'Base 2; escalate:true adds 5 for the stealth browser it may need - the projection is the ceiling, and the actual charge drops back to 2 when the plain fetch succeeded and no escalation ran.'
|
|
779
|
+
: 'Fixed cost per invocation.';
|
|
780
|
+
break;
|
|
769
781
|
case 'agent': {
|
|
770
782
|
const agentUrls = params?.maxUrls || 10;
|
|
771
783
|
const isPro = params?.model === 'pro';
|
|
@@ -8,9 +8,9 @@ import { ResultRanker } from '../tools/search/ranking/ResultRanker.js';
|
|
|
8
8
|
import { CacheManager } from './cache/CacheManager.js';
|
|
9
9
|
import { Logger } from '../utils/Logger.js';
|
|
10
10
|
import { LLMManager } from './llm/LLMManager.js';
|
|
11
|
-
import { safeFetch, safeGoto } from '../utils/ssrfGuard.js';
|
|
11
|
+
import { safeFetch, safeGoto, assertNavigationAllowed } from '../utils/ssrfGuard.js';
|
|
12
12
|
import { serverStealthProxies } from '../constants/config.js';
|
|
13
|
-
import { preflightFetch, browserPreflight } from '../utils/robotsGate.js';
|
|
13
|
+
import { preflightFetch, browserPreflight, redirectGate } from '../utils/robotsGate.js';
|
|
14
14
|
import { guardFirefoxPageErrors } from '../utils/firefoxPageErrorGuard.js';
|
|
15
15
|
import { noteRetryAfter } from '../utils/hostRateLimiter.js';
|
|
16
16
|
import {
|
|
@@ -791,6 +791,7 @@ export class ResearchOrchestrator extends EventEmitter {
|
|
|
791
791
|
const gate = await preflightFetch(source.link, { tool: 'deep_research' });
|
|
792
792
|
const fetchResponse = await safeFetch(source.link, {
|
|
793
793
|
headers: { ...gate.headers },
|
|
794
|
+
onRedirect: gate.onRedirect,
|
|
794
795
|
signal: AbortSignal.timeout(10000)
|
|
795
796
|
});
|
|
796
797
|
if (fetchResponse.status === 429 || fetchResponse.status === 503) {
|
|
@@ -1107,14 +1108,19 @@ export class ResearchOrchestrator extends EventEmitter {
|
|
|
1107
1108
|
}
|
|
1108
1109
|
try {
|
|
1109
1110
|
// browserPreflight (robots + platform blocklist) ran in _stealthFetchHtml;
|
|
1110
|
-
// safeGoto adds the SSRF/DNS-rebinding guard the browser path also needs
|
|
1111
|
-
|
|
1111
|
+
// safeGoto adds the SSRF/DNS-rebinding guard the browser path also needs,
|
|
1112
|
+
// and puts wherever the page is redirected to through the same gate.
|
|
1113
|
+
const onRedirect = redirectGate(url, { tool: 'deep_research' });
|
|
1114
|
+
const resp = await safeGoto(page, url, { waitUntil: 'domcontentloaded', timeout: this.stealthTimeoutMs, onRedirect });
|
|
1115
|
+
const landedUrl = page.url();
|
|
1112
1116
|
// Do NOT bail on the initial HTTP status: anti-bot challenges (Cloudflare
|
|
1113
1117
|
// Turnstile) return 403 on the first response and only resolve to the
|
|
1114
1118
|
// real page after their JS runs. Let it settle, then judge by the
|
|
1115
1119
|
// *rendered* content instead.
|
|
1116
1120
|
await page.waitForLoadState('networkidle', { timeout: 8000 }).catch(() => {});
|
|
1117
1121
|
await page.waitForTimeout(2500).catch(() => {});
|
|
1122
|
+
// A challenge or a client-side redirect may have moved the page on since.
|
|
1123
|
+
await assertNavigationAllowed(page, landedUrl, null, onRedirect);
|
|
1118
1124
|
const html = await page.content();
|
|
1119
1125
|
const title = (await page.title().catch(() => '')) || '';
|
|
1120
1126
|
const bodyLen = await page.evaluate(() => document.body?.innerText?.trim().length || 0).catch(() => 0);
|