crawlforge-mcp-server 6.12.1 → 6.16.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/README.md +1 -1
- package/package.json +4 -3
- package/server.js +68 -22
- package/src/cli/commands/stealth.js +2 -2
- package/src/core/ActionExecutor.js +194 -64
- 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 +29 -6
- package/src/core/browser/actionQueue.js +84 -0
- package/src/core/browser/consent.js +182 -0
- package/src/core/browser/settle.js +141 -0
- package/src/core/browser/snapshot.js +241 -51
- package/src/core/browser/windowState.js +88 -0
- package/src/core/crawlers/BFSCrawler.js +12 -3
- package/src/core/processing/BrowserProcessor.js +19 -7
- 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 +56 -15
- package/src/tools/advanced/ScrapeWithActionsTool.js +74 -53
- 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/HumanBehaviorSimulator.js +35 -14
- 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/sitemapParser.js +2 -1
- package/src/utils/ssrfGuard.js +88 -11
|
@@ -7,9 +7,11 @@ 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
|
+
import { settlePage } from './browser/settle.js';
|
|
14
|
+
import { handleConsent } from './browser/consent.js';
|
|
13
15
|
|
|
14
16
|
// executeJavaScript hardening limits (only relevant when the deploy-time flag
|
|
15
17
|
// ALLOW_JAVASCRIPT_EXECUTION=true is set; JS execution stays off by default).
|
|
@@ -303,17 +305,23 @@ export class ActionExecutor extends EventEmitter {
|
|
|
303
305
|
page = await this.initializePage(url, browserOptions);
|
|
304
306
|
executionContext.page = page;
|
|
305
307
|
|
|
308
|
+
// Cookie/consent wall on the landing page (browserOptions.consent,
|
|
309
|
+
// off unless asked for). A `navigate` action does the same for its
|
|
310
|
+
// page — see executeNavigateAction.
|
|
311
|
+
if (browserOptions.consent && browserOptions.consent !== 'off') {
|
|
312
|
+
executionContext.consent = await handleConsent(page, browserOptions.consent);
|
|
313
|
+
}
|
|
314
|
+
|
|
306
315
|
// Execute chain with potential retries
|
|
307
316
|
chainResult = await this.executeChainWithRetries(executionContext);
|
|
308
317
|
|
|
309
318
|
// Capture the LIVE post-action page state before the page is closed,
|
|
310
319
|
// so callers can extract final content reflecting all actions
|
|
311
320
|
// (instead of re-fetching the original URL).
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
executionContext.
|
|
315
|
-
|
|
316
|
-
this.log('warn', 'Failed to capture final page content: ' + captureErr.message);
|
|
321
|
+
const finalState = await this.captureState(page, browserOptions, 'final page content');
|
|
322
|
+
if (finalState) {
|
|
323
|
+
executionContext.finalHtml = finalState.html;
|
|
324
|
+
executionContext.finalUrl = finalState.url;
|
|
317
325
|
}
|
|
318
326
|
|
|
319
327
|
this.stats.successfulChains++;
|
|
@@ -327,6 +335,9 @@ export class ActionExecutor extends EventEmitter {
|
|
|
327
335
|
// Capture error screenshot if enabled
|
|
328
336
|
if ((validatedChain.screenshotOnError ?? this.enableScreenshotOnError) && page) {
|
|
329
337
|
try {
|
|
338
|
+
// Not of a page the gate has not passed: the chain may have failed
|
|
339
|
+
// for another reason after the page moved.
|
|
340
|
+
await this.assertPageAllowed(page, browserOptions);
|
|
330
341
|
const errorScreenshot = await this.captureScreenshot(page, {
|
|
331
342
|
fullPage: true,
|
|
332
343
|
description: 'Error screenshot'
|
|
@@ -408,6 +419,7 @@ export class ActionExecutor extends EventEmitter {
|
|
|
408
419
|
finalUrl: executionContext.finalUrl || url,
|
|
409
420
|
finalHtml: executionContext.finalHtml,
|
|
410
421
|
navigationStatus: executionContext.page?.__crawlforgeNavigation?.status ?? null,
|
|
422
|
+
consent: executionContext.consent,
|
|
411
423
|
executionTime: Date.now() - startTime,
|
|
412
424
|
results: executionContext.results,
|
|
413
425
|
attempt: executionContext.attempt,
|
|
@@ -463,7 +475,9 @@ export class ActionExecutor extends EventEmitter {
|
|
|
463
475
|
// (form half-filled, menu open, possibly a different URL) is not a
|
|
464
476
|
// retry. Reload the starting URL so every attempt begins where the
|
|
465
477
|
// first one did.
|
|
466
|
-
await this.navigateToUrl(page, executionContext.url
|
|
478
|
+
await this.navigateToUrl(page, executionContext.url, {
|
|
479
|
+
browserOptions: executionContext.browserOptions
|
|
480
|
+
});
|
|
467
481
|
}
|
|
468
482
|
|
|
469
483
|
// Execute actions in sequence
|
|
@@ -476,7 +490,7 @@ export class ActionExecutor extends EventEmitter {
|
|
|
476
490
|
...chain.actions[i],
|
|
477
491
|
timeout: Math.min(chain.actions[i].timeout || chain.timeout, chain.timeout)
|
|
478
492
|
};
|
|
479
|
-
const actionResult = await this.
|
|
493
|
+
const actionResult = await this.executeGatedAction(page, action, executionContext);
|
|
480
494
|
|
|
481
495
|
executionContext.results.push(actionResult);
|
|
482
496
|
this.stats.totalActions++;
|
|
@@ -500,7 +514,7 @@ export class ActionExecutor extends EventEmitter {
|
|
|
500
514
|
|
|
501
515
|
// Handle action failure
|
|
502
516
|
if (!action.continueOnError && !chain.continueOnError) {
|
|
503
|
-
throw new Error('Action failed: ' + actionResult.error);
|
|
517
|
+
throw Object.assign(new Error('Action failed: ' + actionResult.error), { code: actionResult.errorCode });
|
|
504
518
|
}
|
|
505
519
|
}
|
|
506
520
|
|
|
@@ -509,18 +523,16 @@ export class ActionExecutor extends EventEmitter {
|
|
|
509
523
|
// the ALLOW_JAVASCRIPT_EXECUTION flag and doesn't add phantom
|
|
510
524
|
// actions to the chain's failure/success counts.
|
|
511
525
|
if (action.captureAfter) {
|
|
512
|
-
|
|
513
|
-
|
|
526
|
+
const captured = await this.captureState(page, executionContext.browserOptions, 'intermediate state');
|
|
527
|
+
if (captured) {
|
|
514
528
|
executionContext.capturedStates = executionContext.capturedStates || [];
|
|
515
529
|
executionContext.capturedStates.push({
|
|
516
530
|
afterActionIndex: i,
|
|
517
531
|
afterActionId: actionResult.id,
|
|
518
|
-
url:
|
|
519
|
-
html:
|
|
532
|
+
url: captured.url,
|
|
533
|
+
html: captured.html,
|
|
520
534
|
timestamp: Date.now()
|
|
521
535
|
});
|
|
522
|
-
} catch (captureErr) {
|
|
523
|
-
this.log('warn', 'Failed to capture intermediate state: ' + captureErr.message);
|
|
524
536
|
}
|
|
525
537
|
}
|
|
526
538
|
|
|
@@ -541,7 +553,12 @@ export class ActionExecutor extends EventEmitter {
|
|
|
541
553
|
attempt: attempt + 1, success: false, error: error.message, results: [...executionContext.results]
|
|
542
554
|
});
|
|
543
555
|
this.log('warn', 'Chain execution attempt ' + (attempt + 1) + ' failed: ' + error.message);
|
|
544
|
-
|
|
556
|
+
|
|
557
|
+
// A refusal is the gate's answer, not a fault. Replaying the chain
|
|
558
|
+
// asks the same question again, after sending the site every request
|
|
559
|
+
// that led up to it a second time.
|
|
560
|
+
if (gateRefusalCode(error)) break;
|
|
561
|
+
|
|
545
562
|
if (attempt < chain.retryChain) {
|
|
546
563
|
// Wait before retry
|
|
547
564
|
await this.delay(1000 * Math.pow(2, attempt));
|
|
@@ -567,8 +584,12 @@ export class ActionExecutor extends EventEmitter {
|
|
|
567
584
|
*
|
|
568
585
|
* Gating comes for free and must not be duplicated by callers: a `navigate`
|
|
569
586
|
* action goes through executeNavigateAction, which re-runs the SSRF guard and
|
|
570
|
-
* the blocklist/robots gate on every hop (see the comment at its definition)
|
|
571
|
-
*
|
|
587
|
+
* the blocklist/robots gate on every hop (see the comment at its definition),
|
|
588
|
+
* and every action is run through executeGatedAction, which holds the page to
|
|
589
|
+
* the same gate wherever a click or a script has taken it. A refusal there
|
|
590
|
+
* throws rather than failing one action: the page has been emptied, and
|
|
591
|
+
* nothing after it could run. `browserOptions.respectRobots` is what reaches
|
|
592
|
+
* both gates.
|
|
572
593
|
*
|
|
573
594
|
* No `finalHtml` here, unlike the chain: the page is still open afterwards, so
|
|
574
595
|
* content is read from it when it is asked for rather than on every call.
|
|
@@ -604,7 +625,7 @@ export class ActionExecutor extends EventEmitter {
|
|
|
604
625
|
// per-call timeout is applied as the default for actions that set none.
|
|
605
626
|
const action = timeout && !actions[i].timeout ? { ...actions[i], timeout } : actions[i];
|
|
606
627
|
|
|
607
|
-
const actionResult = await this.
|
|
628
|
+
const actionResult = await this.executeGatedAction(page, action, executionContext);
|
|
608
629
|
executionContext.results.push(actionResult);
|
|
609
630
|
this.stats.totalActions++;
|
|
610
631
|
|
|
@@ -628,17 +649,15 @@ export class ActionExecutor extends EventEmitter {
|
|
|
628
649
|
}
|
|
629
650
|
|
|
630
651
|
if (action.captureAfter) {
|
|
631
|
-
|
|
632
|
-
|
|
652
|
+
const captured = await this.captureState(page, browserOptions, 'intermediate state');
|
|
653
|
+
if (captured) {
|
|
633
654
|
executionContext.capturedStates.push({
|
|
634
655
|
afterActionIndex: i,
|
|
635
656
|
afterActionId: actionResult.id,
|
|
636
|
-
url:
|
|
637
|
-
html:
|
|
657
|
+
url: captured.url,
|
|
658
|
+
html: captured.html,
|
|
638
659
|
timestamp: Date.now()
|
|
639
660
|
});
|
|
640
|
-
} catch (captureErr) {
|
|
641
|
-
this.log('warn', 'Failed to capture intermediate state: ' + captureErr.message);
|
|
642
661
|
}
|
|
643
662
|
}
|
|
644
663
|
|
|
@@ -669,6 +688,88 @@ export class ActionExecutor extends EventEmitter {
|
|
|
669
688
|
};
|
|
670
689
|
}
|
|
671
690
|
|
|
691
|
+
/**
|
|
692
|
+
* Hold the page to the gate wherever it stands now.
|
|
693
|
+
*
|
|
694
|
+
* navigateToUrl gates a load and its redirects. After that a page moves
|
|
695
|
+
* without being sent anywhere: a click follows a link, a form posts, a
|
|
696
|
+
* script redirects, a client-side router rewrites the address. Any URL the
|
|
697
|
+
* page has reached since it was last checked gets what a `navigate` to it
|
|
698
|
+
* would: the SSRF guard, then the blocklist/robots gate with this call's
|
|
699
|
+
* `respectRobots`. A refusal leaves the page empty and throws.
|
|
700
|
+
*
|
|
701
|
+
* Cheap when nothing moved (one string comparison), so it is called on both
|
|
702
|
+
* sides of everything that touches the page.
|
|
703
|
+
* @param {Page} page - Playwright page
|
|
704
|
+
* @param {Object} [browserOptions] - Browser options (`respectRobots` override)
|
|
705
|
+
* @returns {Promise<void>}
|
|
706
|
+
* @throws {Error} SSRF_BLOCKED, BlockedHostError or RobotsDisallowedError
|
|
707
|
+
*/
|
|
708
|
+
async assertPageAllowed(page, browserOptions = {}) {
|
|
709
|
+
const gatedUrl = page.__crawlforgeGatedUrl;
|
|
710
|
+
if (page.url() === gatedUrl) return;
|
|
711
|
+
page.__crawlforgeGatedUrl = await assertNavigationAllowed(page, gatedUrl, null, pageMoveGate(gatedUrl, {
|
|
712
|
+
respectRobots: browserOptions?.respectRobots,
|
|
713
|
+
tool: browserOptions?.tool || 'scrape_with_actions'
|
|
714
|
+
}));
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
/**
|
|
718
|
+
* Read from the page with the gate held on both sides of the read, so what
|
|
719
|
+
* comes back is from a document that passed it: the check after the read
|
|
720
|
+
* discards anything read while the page was moving somewhere it may not be.
|
|
721
|
+
* @param {Page} page - Playwright page
|
|
722
|
+
* @param {Object} [browserOptions] - Browser options (`respectRobots` override)
|
|
723
|
+
* @param {() => Promise<T>} read - the read to make
|
|
724
|
+
* @returns {Promise<T>}
|
|
725
|
+
* @template T
|
|
726
|
+
*/
|
|
727
|
+
async readGated(page, browserOptions, read) {
|
|
728
|
+
await this.assertPageAllowed(page, browserOptions);
|
|
729
|
+
const value = await read();
|
|
730
|
+
await this.assertPageAllowed(page, browserOptions);
|
|
731
|
+
return value;
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
/**
|
|
735
|
+
* The page's URL and HTML, read natively (no in-page JS execution, so it
|
|
736
|
+
* works regardless of ALLOW_JAVASCRIPT_EXECUTION). A page that cannot be
|
|
737
|
+
* read yields null and a warning; a page the gate refuses throws.
|
|
738
|
+
* @param {Page} page - Playwright page
|
|
739
|
+
* @param {Object} [browserOptions] - Browser options (`respectRobots` override)
|
|
740
|
+
* @param {string} what - names the capture in the warning
|
|
741
|
+
* @returns {Promise<{ url: string, html: string }|null>}
|
|
742
|
+
*/
|
|
743
|
+
async captureState(page, browserOptions, what) {
|
|
744
|
+
return await this.readGated(page, browserOptions, async () => {
|
|
745
|
+
try {
|
|
746
|
+
return { html: await page.content(), url: page.url() };
|
|
747
|
+
} catch (captureErr) {
|
|
748
|
+
this.log('warn', 'Failed to capture ' + what + ': ' + captureErr.message);
|
|
749
|
+
return null;
|
|
750
|
+
}
|
|
751
|
+
});
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
/**
|
|
755
|
+
* Run one action with the page held to the gate before it and after it.
|
|
756
|
+
* Before, because an action must not read or drive a page that moved while
|
|
757
|
+
* nothing was looking; after, because the action is what usually moves it,
|
|
758
|
+
* and its result is not kept if where it led is refused.
|
|
759
|
+
* @param {Page} page - Playwright page
|
|
760
|
+
* @param {Object} action - Action to execute
|
|
761
|
+
* @param {Object} executionContext - Execution context (`browserOptions` feeds the gate)
|
|
762
|
+
* @returns {Promise<Object>} Action result
|
|
763
|
+
*/
|
|
764
|
+
async executeGatedAction(page, action, executionContext) {
|
|
765
|
+
const browserOptions = executionContext?.browserOptions;
|
|
766
|
+
// A navigate leaves the page it is on and gates the URL it is sent to.
|
|
767
|
+
if (action.type !== 'navigate') await this.assertPageAllowed(page, browserOptions);
|
|
768
|
+
const actionResult = await this.executeActionInternal(page, action, executionContext);
|
|
769
|
+
await this.assertPageAllowed(page, browserOptions);
|
|
770
|
+
return actionResult;
|
|
771
|
+
}
|
|
772
|
+
|
|
672
773
|
/**
|
|
673
774
|
* Execute individual action (original internal method)
|
|
674
775
|
* @param {Page} page - Playwright page
|
|
@@ -743,11 +844,15 @@ export class ActionExecutor extends EventEmitter {
|
|
|
743
844
|
return actionResult;
|
|
744
845
|
|
|
745
846
|
} catch (error) {
|
|
847
|
+
const refusalCode = gateRefusalCode(error);
|
|
746
848
|
const actionResult = {
|
|
747
849
|
id: actionId,
|
|
748
850
|
type: action.type,
|
|
749
851
|
success: false,
|
|
750
852
|
error: error.message,
|
|
853
|
+
// Only when the gate refused (a `navigate` to a URL it would not
|
|
854
|
+
// load), so the chain can tell an answer from a fault.
|
|
855
|
+
...(refusalCode ? { errorCode: refusalCode } : {}),
|
|
751
856
|
executionTime: Date.now() - actionStartTime,
|
|
752
857
|
timestamp: Date.now(),
|
|
753
858
|
description: action.description
|
|
@@ -799,14 +904,15 @@ export class ActionExecutor extends EventEmitter {
|
|
|
799
904
|
* Resolve a caller-supplied selector.
|
|
800
905
|
*
|
|
801
906
|
* A selector starting with `@` names a ref a prior snapshot action assigned,
|
|
802
|
-
* and resolves to the
|
|
803
|
-
*
|
|
907
|
+
* and resolves to the Playwright selector that snapshot recorded for it
|
|
908
|
+
* (`aria-ref=…` from the native snapshot, `[data-cf-ref=…]` from the walk
|
|
909
|
+
* fallback); anything else is already a CSS selector. Every selector a caller
|
|
804
910
|
* writes goes through here, so refs work in every action type without any
|
|
805
|
-
* schema change —
|
|
806
|
-
*
|
|
911
|
+
* schema change — including the stealth human-behaviour paths, which take
|
|
912
|
+
* Playwright selectors.
|
|
807
913
|
* @param {Page} page - Playwright page
|
|
808
914
|
* @param {string} selector - CSS selector, or an `@e1` snapshot ref
|
|
809
|
-
* @returns {string}
|
|
915
|
+
* @returns {string} Playwright selector
|
|
810
916
|
*/
|
|
811
917
|
resolveSelector(page, selector) {
|
|
812
918
|
return isRef(selector) ? resolveRef(page, selector) : selector;
|
|
@@ -862,23 +968,25 @@ export class ActionExecutor extends EventEmitter {
|
|
|
862
968
|
}
|
|
863
969
|
|
|
864
970
|
/**
|
|
865
|
-
*
|
|
971
|
+
* Let the page settle after the action that just ran — a navigation commit
|
|
972
|
+
* and load, or an SPA re-render that fires no load event at all.
|
|
866
973
|
*
|
|
867
974
|
* Playwright auto-waits on the element it acts on, but nothing waits on the
|
|
868
|
-
* *document* a click or keypress may have
|
|
869
|
-
* run against the outgoing page.
|
|
870
|
-
*
|
|
871
|
-
*
|
|
975
|
+
* *document* a click or keypress may have changed, so the next action could
|
|
976
|
+
* run against the outgoing page. settlePage waits for `load` and then a quiet
|
|
977
|
+
* window (no DOM mutations, no requests in flight), capped and never longer
|
|
978
|
+
* than what is left of the action's `timeout`; a page that doesn't settle in
|
|
979
|
+
* time is not itself an action failure, so it never throws.
|
|
872
980
|
* @param {Page} page - Playwright page
|
|
873
|
-
* @param {number} timeout -
|
|
874
|
-
* @
|
|
981
|
+
* @param {number} timeout - The action's timeout in ms
|
|
982
|
+
* @param {number} startedAt - When the action started (Date.now()); the settle
|
|
983
|
+
* gets only the remainder, so a slow element wait plus a busy page cannot
|
|
984
|
+
* outrun the backstop and turn a successful action into a timeout
|
|
985
|
+
* @returns {Promise<{waited_ms: number, settled_by: string}>}
|
|
875
986
|
*/
|
|
876
|
-
async settleAfterInteraction(page, timeout) {
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
} catch {
|
|
880
|
-
// Ignored on purpose — see above.
|
|
881
|
-
}
|
|
987
|
+
async settleAfterInteraction(page, timeout, startedAt) {
|
|
988
|
+
const remaining = Math.max(1, timeout - (Date.now() - startedAt));
|
|
989
|
+
return await settlePage(page, { timeout: remaining });
|
|
882
990
|
}
|
|
883
991
|
|
|
884
992
|
/**
|
|
@@ -960,6 +1068,7 @@ export class ActionExecutor extends EventEmitter {
|
|
|
960
1068
|
* @returns {Promise<Object>} Click result
|
|
961
1069
|
*/
|
|
962
1070
|
async executeClickAction(page, action) {
|
|
1071
|
+
const startedAt = Date.now();
|
|
963
1072
|
const timeout = this.actionTimeout(action);
|
|
964
1073
|
const locator = this.elementLocator(page, action.selector);
|
|
965
1074
|
|
|
@@ -977,7 +1086,8 @@ export class ActionExecutor extends EventEmitter {
|
|
|
977
1086
|
button: action.button,
|
|
978
1087
|
clickCount: action.clickCount,
|
|
979
1088
|
delay: action.delay,
|
|
980
|
-
force: action.force
|
|
1089
|
+
force: action.force,
|
|
1090
|
+
timeout
|
|
981
1091
|
});
|
|
982
1092
|
} else {
|
|
983
1093
|
// Standard click behavior
|
|
@@ -998,7 +1108,7 @@ export class ActionExecutor extends EventEmitter {
|
|
|
998
1108
|
|
|
999
1109
|
// A click can follow a link or submit a form; let that navigation commit
|
|
1000
1110
|
// before the next action runs against the outgoing document.
|
|
1001
|
-
await this.settleAfterInteraction(page, timeout);
|
|
1111
|
+
await this.settleAfterInteraction(page, timeout, startedAt);
|
|
1002
1112
|
|
|
1003
1113
|
return {
|
|
1004
1114
|
selector: action.selector,
|
|
@@ -1050,6 +1160,7 @@ export class ActionExecutor extends EventEmitter {
|
|
|
1050
1160
|
* @returns {Promise<Object>} Press result
|
|
1051
1161
|
*/
|
|
1052
1162
|
async executePressAction(page, action) {
|
|
1163
|
+
const startedAt = Date.now();
|
|
1053
1164
|
const timeout = this.actionTimeout(action);
|
|
1054
1165
|
const keyOptions = { timeout };
|
|
1055
1166
|
if (action.modifiers?.length > 0) {
|
|
@@ -1063,7 +1174,7 @@ export class ActionExecutor extends EventEmitter {
|
|
|
1063
1174
|
}
|
|
1064
1175
|
|
|
1065
1176
|
// Enter on a form field navigates as often as a click does.
|
|
1066
|
-
await this.settleAfterInteraction(page, timeout);
|
|
1177
|
+
await this.settleAfterInteraction(page, timeout, startedAt);
|
|
1067
1178
|
|
|
1068
1179
|
return {
|
|
1069
1180
|
key: action.key,
|
|
@@ -1161,6 +1272,7 @@ export class ActionExecutor extends EventEmitter {
|
|
|
1161
1272
|
* @returns {Promise<Object>} Select result
|
|
1162
1273
|
*/
|
|
1163
1274
|
async executeSelectAction(page, action) {
|
|
1275
|
+
const startedAt = Date.now();
|
|
1164
1276
|
const timeout = this.actionTimeout(action);
|
|
1165
1277
|
const values = action.values?.length ? action.values : [action.value];
|
|
1166
1278
|
|
|
@@ -1169,7 +1281,7 @@ export class ActionExecutor extends EventEmitter {
|
|
|
1169
1281
|
|
|
1170
1282
|
// Faceted-search dropdowns commonly submit the form on change, so let any
|
|
1171
1283
|
// navigation commit before the next action runs.
|
|
1172
|
-
await this.settleAfterInteraction(page, timeout);
|
|
1284
|
+
await this.settleAfterInteraction(page, timeout, startedAt);
|
|
1173
1285
|
|
|
1174
1286
|
return {
|
|
1175
1287
|
selector: action.selector,
|
|
@@ -1214,6 +1326,7 @@ export class ActionExecutor extends EventEmitter {
|
|
|
1214
1326
|
* @returns {Promise<Object>} Navigate result
|
|
1215
1327
|
*/
|
|
1216
1328
|
async executeNavigateAction(page, action, executionContext) {
|
|
1329
|
+
const startedAt = Date.now();
|
|
1217
1330
|
const timeout = this.actionTimeout(action);
|
|
1218
1331
|
|
|
1219
1332
|
await assertUrlAllowed(action.url, { resolveDns: true });
|
|
@@ -1221,14 +1334,23 @@ export class ActionExecutor extends EventEmitter {
|
|
|
1221
1334
|
|
|
1222
1335
|
await this.navigateToUrl(page, action.url, {
|
|
1223
1336
|
waitUntil: action.waitUntil,
|
|
1224
|
-
timeout
|
|
1337
|
+
timeout,
|
|
1338
|
+
browserOptions: executionContext?.browserOptions
|
|
1225
1339
|
});
|
|
1226
1340
|
page.__crawlforgeGateWarnings = gateWarnings;
|
|
1227
1341
|
|
|
1342
|
+
// Consent handling gets at most what is left of the action's deadline, so
|
|
1343
|
+
// it cannot push a navigation that succeeded past the backstop.
|
|
1344
|
+
const consentMode = executionContext?.browserOptions?.consent;
|
|
1345
|
+
const consent = consentMode && consentMode !== 'off'
|
|
1346
|
+
? await handleConsent(page, consentMode, { timeout: Math.min(2000, timeout - (Date.now() - startedAt)) })
|
|
1347
|
+
: undefined;
|
|
1348
|
+
|
|
1228
1349
|
return {
|
|
1229
1350
|
url: action.url,
|
|
1230
1351
|
finalUrl: page.url(),
|
|
1231
|
-
waitUntil: action.waitUntil || 'domcontentloaded'
|
|
1352
|
+
waitUntil: action.waitUntil || 'domcontentloaded',
|
|
1353
|
+
...(consent ? { consent } : {})
|
|
1232
1354
|
};
|
|
1233
1355
|
}
|
|
1234
1356
|
|
|
@@ -1314,8 +1436,8 @@ export class ActionExecutor extends EventEmitter {
|
|
|
1314
1436
|
}
|
|
1315
1437
|
|
|
1316
1438
|
/**
|
|
1317
|
-
* Execute snapshot action - the page's interactive elements, each
|
|
1318
|
-
*
|
|
1439
|
+
* Execute snapshot action - the page's interactive elements, each carrying
|
|
1440
|
+
* a ref later actions can target instead of a guessed CSS selector.
|
|
1319
1441
|
* @param {Page} page - Playwright page
|
|
1320
1442
|
* @param {Object} action - Snapshot action
|
|
1321
1443
|
* @returns {Promise<Object>} Snapshot tree with refs
|
|
@@ -1323,7 +1445,9 @@ export class ActionExecutor extends EventEmitter {
|
|
|
1323
1445
|
async executeSnapshotAction(page, action) {
|
|
1324
1446
|
return await captureSnapshot(page, {
|
|
1325
1447
|
interactiveOnly: action.interactiveOnly,
|
|
1326
|
-
maxNodes: action.maxNodes
|
|
1448
|
+
maxNodes: action.maxNodes,
|
|
1449
|
+
// The render wait before the walk stays inside the action's deadline.
|
|
1450
|
+
timeout: this.actionTimeout(action)
|
|
1327
1451
|
});
|
|
1328
1452
|
}
|
|
1329
1453
|
|
|
@@ -1364,12 +1488,14 @@ export class ActionExecutor extends EventEmitter {
|
|
|
1364
1488
|
}
|
|
1365
1489
|
|
|
1366
1490
|
/**
|
|
1367
|
-
* Navigate an existing page to a URL under the SSRF checks every load needs
|
|
1368
|
-
*
|
|
1369
|
-
*
|
|
1491
|
+
* Navigate an existing page to a URL under the SSRF checks every load needs,
|
|
1492
|
+
* and put wherever it is redirected to through the same blocklist/robots
|
|
1493
|
+
* gate the URL itself passed (assertRobotsAllowed). Used for the initial
|
|
1494
|
+
* load, again before each chain retry, and by the `navigate` action.
|
|
1370
1495
|
* @param {Page} page - Playwright page
|
|
1371
1496
|
* @param {string} url - URL to navigate to
|
|
1372
|
-
* @param {{ waitUntil?: string, timeout?: number }} [options] -
|
|
1497
|
+
* @param {{ waitUntil?: string, timeout?: number, browserOptions?: Object }} [options] -
|
|
1498
|
+
* Navigation options; `browserOptions` carries `respectRobots` to the redirect gate
|
|
1373
1499
|
* @returns {Promise<void>}
|
|
1374
1500
|
*/
|
|
1375
1501
|
async navigateToUrl(page, url, options = {}) {
|
|
@@ -1382,12 +1508,16 @@ export class ActionExecutor extends EventEmitter {
|
|
|
1382
1508
|
timeout: options.timeout || 30000
|
|
1383
1509
|
});
|
|
1384
1510
|
|
|
1385
|
-
// Re-validate the
|
|
1386
|
-
//
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1511
|
+
// Re-validate where the navigation went: a redirect could have taken us
|
|
1512
|
+
// into a blocked range, onto a blocklisted host or into a path robots.txt
|
|
1513
|
+
// disallows, even though the original URL was fine. A refusal leaves the
|
|
1514
|
+
// page empty, so a session that keeps it cannot read the refused document.
|
|
1515
|
+
const landedUrl = await assertNavigationAllowed(page, url, response, redirectGate(url, {
|
|
1516
|
+
respectRobots: options.browserOptions?.respectRobots,
|
|
1517
|
+
tool: options.browserOptions?.tool || 'scrape_with_actions'
|
|
1518
|
+
}));
|
|
1519
|
+
// What assertPageAllowed compares the page against from here on.
|
|
1520
|
+
page.__crawlforgeGatedUrl = landedUrl;
|
|
1391
1521
|
|
|
1392
1522
|
// Remembered on the page so the chain result can report the status of
|
|
1393
1523
|
// the last navigation: tesla.com's Akamai denial ran a full action chain
|
|
@@ -1461,7 +1591,7 @@ export class ActionExecutor extends EventEmitter {
|
|
|
1461
1591
|
// Navigate to URL. The pre-flight above repeats inside navigateToUrl —
|
|
1462
1592
|
// that one is deliberately before page creation so a blocked URL never
|
|
1463
1593
|
// launches a browser (tests/unit/phase1-ssrf-paths.test.js pins it).
|
|
1464
|
-
await this.navigateToUrl(page, url);
|
|
1594
|
+
await this.navigateToUrl(page, url, { browserOptions });
|
|
1465
1595
|
|
|
1466
1596
|
// Handle CloudFlare challenges and reCAPTCHA if stealth mode is enabled
|
|
1467
1597
|
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);
|