crawlforge-mcp-server 5.0.5 → 5.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +9 -6
- package/README.md +28 -8
- package/package.json +6 -5
- package/server.js +56 -16
- package/src/core/ActionExecutor.js +246 -66
- package/src/core/AuthManager.js +1 -0
- package/src/core/ChangeTracker.js +215 -22
- package/src/core/ResearchOrchestrator.js +9 -3
- package/src/core/SamplingClient.js +4 -5
- package/src/core/StealthBrowserManager.js +64 -18
- package/src/core/cache/CacheManager.js +7 -2
- package/src/core/crawlers/BFSCrawler.js +14 -6
- package/src/core/llm/LLMManager.js +61 -11
- package/src/core/llm/OllamaProvider.js +139 -0
- package/src/core/processing/BrowserProcessor.js +28 -2
- package/src/schemas/toolOutputSchemas.js +53 -1
- package/src/server/requestContext.js +26 -0
- package/src/server/transports/streamableHttp.js +54 -11
- package/src/server/withAuth.js +24 -6
- package/src/skills/agent-skills/crawlforge-deep-research/SKILL.md +26 -3
- package/src/skills/agent-skills/crawlforge-getting-started/SKILL.md +5 -4
- package/src/skills/agent-skills/crawlforge-getting-started/references/credits.md +1 -0
- package/src/skills/agent-skills/crawlforge-structured-extraction/SKILL.md +6 -4
- package/src/skills/agent-skills/crawlforge-structured-extraction/references/templates.md +2 -1
- package/src/tools/advanced/ScrapeWithActionsTool.js +4 -1
- package/src/tools/basic/_fetch.js +8 -2
- package/src/tools/basic/fetchUrl.js +4 -1
- package/src/tools/crawl/crawlDeep.js +19 -5
- package/src/tools/extract/extractStructured.js +16 -4
- package/src/tools/extract/extractWithLlm.js +80 -10
- package/src/tools/extract/listOllamaModels.js +4 -6
- package/src/tools/scrape/_brandingExtractor.js +1 -1
- package/src/tools/scrape/unifiedScrape.js +71 -5
- package/src/tools/search/adapters/redditOfficialApi.js +196 -0
- package/src/tools/search/redditNormalize.js +95 -0
- package/src/tools/search/redditSearch.js +326 -0
- package/src/tools/templates/ScrapeTemplateTool.js +8 -3
- package/src/utils/hiddenContent.js +330 -0
- package/src/utils/htmlToMarkdown.js +12 -2
- package/src/utils/ollamaConfig.js +121 -0
- package/src/tools/templates/TemplateRegistry.js +0 -325
|
@@ -14,13 +14,37 @@ import { assertUrlAllowed } from '../utils/ssrfGuard.js';
|
|
|
14
14
|
const JS_MAX_SCRIPT_LENGTH = parseInt(process.env.JS_MAX_SCRIPT_LENGTH || '10000', 10);
|
|
15
15
|
const JS_EXECUTION_TIMEOUT_MS = parseInt(process.env.JS_EXECUTION_TIMEOUT_MS || '5000', 10);
|
|
16
16
|
|
|
17
|
+
// Headroom for the per-action backstop in executeActionInternal. The underlying
|
|
18
|
+
// Playwright call gets the action's real deadline, so the backstop must lose
|
|
19
|
+
// that race — Playwright's error names the selector and the state it waited
|
|
20
|
+
// for, the backstop can only say "timed out".
|
|
21
|
+
const ACTION_TIMEOUT_GRACE_MS = 2000;
|
|
22
|
+
|
|
23
|
+
// Ceiling for a single error-recovery strategy. By the time recovery runs the
|
|
24
|
+
// action has already spent its whole timeout failing, so each strategy gets a
|
|
25
|
+
// bounded slice — granting it another full deadline made a chain that was never
|
|
26
|
+
// going to work cost several times its stated timeout.
|
|
27
|
+
const RECOVERY_TIMEOUT_MS = 5000;
|
|
28
|
+
|
|
29
|
+
// The only states locator.waitFor()/page.waitForSelector() accept. The rest of
|
|
30
|
+
// the wait-action enum (enabled/disabled/stable) are ElementHandle states and
|
|
31
|
+
// have to go through waitForElementState instead — passing them here is
|
|
32
|
+
// rejected outright with "expected one of (attached|detached|visible|hidden)".
|
|
33
|
+
const SELECTOR_WAIT_STATES = new Set(['attached', 'detached', 'visible', 'hidden']);
|
|
34
|
+
|
|
17
35
|
// Action schemas
|
|
18
36
|
const BaseActionSchema = z.object({
|
|
19
37
|
type: z.string(),
|
|
20
38
|
timeout: z.number().optional(),
|
|
21
39
|
description: z.string().optional(),
|
|
22
40
|
continueOnError: z.boolean().default(false),
|
|
23
|
-
|
|
41
|
+
// How many of the recovery strategies registered in
|
|
42
|
+
// initializeErrorRecoveryStrategies() this action may try (in order, until
|
|
43
|
+
// one succeeds); 0 opts out. Defaults to 1 — the previous 0 default combined
|
|
44
|
+
// with the `action.retries > 0` gate in executeActionInternal left every
|
|
45
|
+
// strategy unreachable. ScrapeWithActionsTool's form-autofill presets already
|
|
46
|
+
// set 1 and 2 on exactly the actions that have that many strategies.
|
|
47
|
+
retries: z.number().min(0).max(5).default(1),
|
|
24
48
|
// When true, capture page state (page.content()/page.url()) natively right
|
|
25
49
|
// after this action executes. Does not use in-page JS execution, so it
|
|
26
50
|
// works regardless of the ALLOW_JAVASCRIPT_EXECUTION flag.
|
|
@@ -368,6 +392,11 @@ export class ActionExecutor extends EventEmitter {
|
|
|
368
392
|
this.log('info', 'Retrying chain execution, attempt ' + (attempt + 1));
|
|
369
393
|
executionContext.results = []; // Clear previous results on retry
|
|
370
394
|
executionContext.capturedStates = []; // Clear previous captures on retry
|
|
395
|
+
// Replaying the chain against whatever the failed attempt left behind
|
|
396
|
+
// (form half-filled, menu open, possibly a different URL) is not a
|
|
397
|
+
// retry. Reload the starting URL so every attempt begins where the
|
|
398
|
+
// first one did.
|
|
399
|
+
await this.navigateToUrl(page, executionContext.url);
|
|
371
400
|
}
|
|
372
401
|
|
|
373
402
|
// Execute actions in sequence
|
|
@@ -463,7 +492,8 @@ export class ActionExecutor extends EventEmitter {
|
|
|
463
492
|
this.emit('actionStarted', { actionId, action, chainId: executionContext.id });
|
|
464
493
|
|
|
465
494
|
let result;
|
|
466
|
-
|
|
495
|
+
// Deadline handed to the underlying Playwright call — see actionTimeout().
|
|
496
|
+
let timeout = this.actionTimeout(action);
|
|
467
497
|
|
|
468
498
|
// A `wait` action that uses `timeout` as its pause duration (no
|
|
469
499
|
// duration/milliseconds/selector/text) must not also use that same value
|
|
@@ -474,13 +504,33 @@ export class ActionExecutor extends EventEmitter {
|
|
|
474
504
|
timeout = Math.max(this.defaultTimeout, action.timeout + 5000);
|
|
475
505
|
}
|
|
476
506
|
|
|
477
|
-
// Execute based on action type
|
|
507
|
+
// Execute based on action type. Playwright owns the real deadline (every
|
|
508
|
+
// call below is given `timeout`), so this race is only a backstop for a
|
|
509
|
+
// call that hangs past it — a wedged browser, say. Without the grace
|
|
510
|
+
// period it fired first on every ordinary failure and replaced
|
|
511
|
+
// Playwright's "waiting for locator('#x') to be visible" with a bare
|
|
512
|
+
// "Action timeout".
|
|
513
|
+
const backstopMs = timeout + ACTION_TIMEOUT_GRACE_MS;
|
|
514
|
+
let backstopTimer;
|
|
478
515
|
const executionPromise = this.executeActionByType(page, action);
|
|
479
516
|
const timeoutPromise = new Promise((_, reject) => {
|
|
480
|
-
|
|
517
|
+
backstopTimer = setTimeout(
|
|
518
|
+
() => reject(new Error(
|
|
519
|
+
'Action backstop timeout: ' + action.type +
|
|
520
|
+
(action.selector ? ' (' + action.selector + ')' : '') +
|
|
521
|
+
' did not settle within ' + backstopMs + 'ms'
|
|
522
|
+
)),
|
|
523
|
+
backstopMs
|
|
524
|
+
);
|
|
481
525
|
});
|
|
482
526
|
|
|
483
|
-
|
|
527
|
+
try {
|
|
528
|
+
result = await Promise.race([executionPromise, timeoutPromise]);
|
|
529
|
+
} finally {
|
|
530
|
+
// Without this every action left a live timer behind for its full
|
|
531
|
+
// deadline, keeping the event loop busy long after the chain finished.
|
|
532
|
+
clearTimeout(backstopTimer);
|
|
533
|
+
}
|
|
484
534
|
|
|
485
535
|
const actionResult = {
|
|
486
536
|
id: actionId,
|
|
@@ -523,6 +573,94 @@ export class ActionExecutor extends EventEmitter {
|
|
|
523
573
|
}
|
|
524
574
|
}
|
|
525
575
|
|
|
576
|
+
/**
|
|
577
|
+
* Deadline to hand the underlying Playwright call for an action, so a failure
|
|
578
|
+
* surfaces Playwright's own error rather than the generic backstop.
|
|
579
|
+
* @param {Object} action - Action configuration
|
|
580
|
+
* @returns {number} Timeout in ms
|
|
581
|
+
*/
|
|
582
|
+
actionTimeout(action) {
|
|
583
|
+
return action?.timeout || this.defaultTimeout;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* Deadline for one recovery strategy — bounded, see RECOVERY_TIMEOUT_MS.
|
|
588
|
+
* @param {Object} action - Action configuration
|
|
589
|
+
* @returns {number} Timeout in ms
|
|
590
|
+
*/
|
|
591
|
+
recoveryTimeout(action) {
|
|
592
|
+
return Math.min(this.actionTimeout(action), RECOVERY_TIMEOUT_MS);
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/**
|
|
596
|
+
* Locator for an action's selector.
|
|
597
|
+
*
|
|
598
|
+
* `.first()` preserves the first-match semantics of the page.waitForSelector()
|
|
599
|
+
* calls this replaced: locators are strict by default and throw on any
|
|
600
|
+
* selector matching more than one element, which would break action chains
|
|
601
|
+
* that work today.
|
|
602
|
+
* @param {Page} page - Playwright page
|
|
603
|
+
* @param {string} selector - CSS/text selector
|
|
604
|
+
* @returns {Locator} Playwright locator
|
|
605
|
+
*/
|
|
606
|
+
elementLocator(page, selector) {
|
|
607
|
+
return page.locator(selector).first();
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/**
|
|
611
|
+
* Wait for a selector to reach a condition.
|
|
612
|
+
*
|
|
613
|
+
* Playwright splits these across two APIs: attached/detached/visible/hidden
|
|
614
|
+
* are selector states (locator.waitFor), while enabled/disabled/stable are
|
|
615
|
+
* element states (ElementHandle.waitForElementState). Handing the latter to
|
|
616
|
+
* waitForSelector is rejected outright, which is what made those three
|
|
617
|
+
* documented wait conditions unusable.
|
|
618
|
+
* @param {Page} page - Playwright page
|
|
619
|
+
* @param {string} selector - CSS/text selector
|
|
620
|
+
* @param {string} [condition] - Wait condition
|
|
621
|
+
* @param {number} timeout - Timeout in ms
|
|
622
|
+
* @returns {Promise<void>}
|
|
623
|
+
*/
|
|
624
|
+
async waitForCondition(page, selector, condition, timeout) {
|
|
625
|
+
const locator = this.elementLocator(page, selector);
|
|
626
|
+
|
|
627
|
+
if (!condition || SELECTOR_WAIT_STATES.has(condition)) {
|
|
628
|
+
await locator.waitFor({ state: condition || 'visible', timeout });
|
|
629
|
+
return;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
await locator.waitFor({ state: 'attached', timeout });
|
|
633
|
+
const handle = await locator.elementHandle({ timeout });
|
|
634
|
+
if (!handle) {
|
|
635
|
+
throw new Error('No element matched selector: ' + selector);
|
|
636
|
+
}
|
|
637
|
+
try {
|
|
638
|
+
await handle.waitForElementState(condition, { timeout });
|
|
639
|
+
} finally {
|
|
640
|
+
await handle.dispose();
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
/**
|
|
645
|
+
* Give a navigation started by the action that just ran a chance to commit.
|
|
646
|
+
*
|
|
647
|
+
* Playwright auto-waits on the element it acts on, but nothing waits on the
|
|
648
|
+
* *document* a click or keypress may have replaced, so the next action could
|
|
649
|
+
* run against the outgoing page. A page that never navigated is already past
|
|
650
|
+
* this state and returns immediately; a page that doesn't settle in time is
|
|
651
|
+
* not itself an action failure, hence the catch.
|
|
652
|
+
* @param {Page} page - Playwright page
|
|
653
|
+
* @param {number} timeout - Timeout in ms
|
|
654
|
+
* @returns {Promise<void>}
|
|
655
|
+
*/
|
|
656
|
+
async settleAfterInteraction(page, timeout) {
|
|
657
|
+
try {
|
|
658
|
+
await page.waitForLoadState('domcontentloaded', { timeout });
|
|
659
|
+
} catch {
|
|
660
|
+
// Ignored on purpose — see above.
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
|
|
526
664
|
/**
|
|
527
665
|
* Execute action based on its type
|
|
528
666
|
* @param {Page} page - Playwright page
|
|
@@ -567,20 +705,18 @@ export class ActionExecutor extends EventEmitter {
|
|
|
567
705
|
return { waited: waitTime };
|
|
568
706
|
}
|
|
569
707
|
|
|
708
|
+
const timeout = this.actionTimeout(action);
|
|
709
|
+
|
|
570
710
|
if (action.selector) {
|
|
571
|
-
|
|
572
|
-
if (action.condition) {
|
|
573
|
-
options.state = action.condition;
|
|
574
|
-
}
|
|
575
|
-
|
|
576
|
-
await page.waitForSelector(action.selector, options);
|
|
711
|
+
await this.waitForCondition(page, action.selector, action.condition, timeout);
|
|
577
712
|
return { selector: action.selector, condition: action.condition };
|
|
578
713
|
}
|
|
579
714
|
|
|
580
715
|
if (action.text) {
|
|
581
716
|
await page.waitForFunction(
|
|
582
717
|
text => document.body.innerText.includes(text),
|
|
583
|
-
action.text
|
|
718
|
+
action.text,
|
|
719
|
+
{ timeout }
|
|
584
720
|
);
|
|
585
721
|
return { text: action.text };
|
|
586
722
|
}
|
|
@@ -595,12 +731,16 @@ export class ActionExecutor extends EventEmitter {
|
|
|
595
731
|
* @returns {Promise<Object>} Click result
|
|
596
732
|
*/
|
|
597
733
|
async executeClickAction(page, action) {
|
|
598
|
-
const
|
|
599
|
-
|
|
734
|
+
const timeout = this.actionTimeout(action);
|
|
735
|
+
const locator = this.elementLocator(page, action.selector);
|
|
736
|
+
|
|
600
737
|
// Check if stealth mode is enabled and use human behavior
|
|
601
738
|
const humanBehaviorSimulator = this.browserProcessor.stealthManager?.humanBehaviorSimulator;
|
|
602
739
|
|
|
603
740
|
if (humanBehaviorSimulator) {
|
|
741
|
+
// The simulator drives the mouse by selector, so the element still has to
|
|
742
|
+
// be there before it starts (locator.click() would have waited for it).
|
|
743
|
+
await locator.waitFor({ state: 'visible', timeout });
|
|
604
744
|
// Use human-like clicking behavior
|
|
605
745
|
await humanBehaviorSimulator.simulateClick(page, action.selector, {
|
|
606
746
|
button: action.button,
|
|
@@ -614,16 +754,21 @@ export class ActionExecutor extends EventEmitter {
|
|
|
614
754
|
button: action.button,
|
|
615
755
|
clickCount: action.clickCount,
|
|
616
756
|
delay: action.delay,
|
|
617
|
-
force: action.force
|
|
757
|
+
force: action.force,
|
|
758
|
+
timeout
|
|
618
759
|
};
|
|
619
760
|
|
|
620
761
|
if (action.position) {
|
|
621
762
|
clickOptions.position = action.position;
|
|
622
763
|
}
|
|
623
764
|
|
|
624
|
-
await
|
|
765
|
+
await locator.click(clickOptions);
|
|
625
766
|
}
|
|
626
|
-
|
|
767
|
+
|
|
768
|
+
// A click can follow a link or submit a form; let that navigation commit
|
|
769
|
+
// before the next action runs against the outgoing document.
|
|
770
|
+
await this.settleAfterInteraction(page, timeout);
|
|
771
|
+
|
|
627
772
|
return {
|
|
628
773
|
selector: action.selector,
|
|
629
774
|
button: action.button,
|
|
@@ -639,22 +784,25 @@ export class ActionExecutor extends EventEmitter {
|
|
|
639
784
|
* @returns {Promise<Object>} Type result
|
|
640
785
|
*/
|
|
641
786
|
async executeTypeAction(page, action) {
|
|
642
|
-
const
|
|
643
|
-
|
|
787
|
+
const timeout = this.actionTimeout(action);
|
|
788
|
+
const locator = this.elementLocator(page, action.selector);
|
|
789
|
+
|
|
644
790
|
// Check if stealth mode is enabled and use human behavior
|
|
645
791
|
const humanBehaviorSimulator = this.browserProcessor.stealthManager?.humanBehaviorSimulator;
|
|
646
|
-
|
|
792
|
+
|
|
647
793
|
if (action.clear) {
|
|
648
|
-
await
|
|
649
|
-
await
|
|
794
|
+
await locator.selectText({ timeout });
|
|
795
|
+
await locator.press('Delete', { timeout });
|
|
650
796
|
}
|
|
651
797
|
|
|
652
798
|
if (humanBehaviorSimulator) {
|
|
799
|
+
// Same as click: the simulator works from the selector, so wait first.
|
|
800
|
+
await locator.waitFor({ state: 'visible', timeout });
|
|
653
801
|
// Use human-like typing behavior
|
|
654
802
|
await humanBehaviorSimulator.simulateTyping(page, action.selector, action.text);
|
|
655
803
|
} else {
|
|
656
804
|
// Standard typing behavior
|
|
657
|
-
await
|
|
805
|
+
await locator.pressSequentially(action.text, { delay: action.delay, timeout });
|
|
658
806
|
}
|
|
659
807
|
|
|
660
808
|
return {
|
|
@@ -671,18 +819,21 @@ export class ActionExecutor extends EventEmitter {
|
|
|
671
819
|
* @returns {Promise<Object>} Press result
|
|
672
820
|
*/
|
|
673
821
|
async executePressAction(page, action) {
|
|
674
|
-
const
|
|
675
|
-
|
|
822
|
+
const timeout = this.actionTimeout(action);
|
|
823
|
+
const keyOptions = { timeout };
|
|
824
|
+
if (action.modifiers?.length > 0) {
|
|
676
825
|
keyOptions.modifiers = action.modifiers;
|
|
677
826
|
}
|
|
678
827
|
|
|
679
828
|
if (action.selector) {
|
|
680
|
-
|
|
681
|
-
await element.press(action.key, keyOptions);
|
|
829
|
+
await this.elementLocator(page, action.selector).press(action.key, keyOptions);
|
|
682
830
|
} else {
|
|
683
831
|
await page.keyboard.press(action.key);
|
|
684
832
|
}
|
|
685
|
-
|
|
833
|
+
|
|
834
|
+
// Enter on a form field navigates as often as a click does.
|
|
835
|
+
await this.settleAfterInteraction(page, timeout);
|
|
836
|
+
|
|
686
837
|
return {
|
|
687
838
|
key: action.key,
|
|
688
839
|
modifiers: action.modifiers,
|
|
@@ -697,6 +848,8 @@ export class ActionExecutor extends EventEmitter {
|
|
|
697
848
|
* @returns {Promise<Object>} Scroll result
|
|
698
849
|
*/
|
|
699
850
|
async executeScrollAction(page, action) {
|
|
851
|
+
const timeout = this.actionTimeout(action);
|
|
852
|
+
|
|
700
853
|
// Check if stealth mode is enabled and use human behavior
|
|
701
854
|
const humanBehaviorSimulator = this.browserProcessor.stealthManager?.humanBehaviorSimulator;
|
|
702
855
|
|
|
@@ -707,8 +860,11 @@ export class ActionExecutor extends EventEmitter {
|
|
|
707
860
|
target: action.toElement
|
|
708
861
|
});
|
|
709
862
|
} else {
|
|
710
|
-
|
|
711
|
-
|
|
863
|
+
// scrollIntoViewIfNeeded, not scrollIntoView — the latter is a DOM API
|
|
864
|
+
// that does not exist on a Playwright handle/locator and threw
|
|
865
|
+
// "scrollIntoView is not a function" every time this branch ran.
|
|
866
|
+
await this.elementLocator(page, action.toElement)
|
|
867
|
+
.scrollIntoViewIfNeeded({ timeout });
|
|
712
868
|
}
|
|
713
869
|
return { scrolledToElement: action.toElement };
|
|
714
870
|
}
|
|
@@ -753,8 +909,7 @@ export class ActionExecutor extends EventEmitter {
|
|
|
753
909
|
}
|
|
754
910
|
|
|
755
911
|
if (action.selector) {
|
|
756
|
-
|
|
757
|
-
await element.hover();
|
|
912
|
+
await this.elementLocator(page, action.selector).hover({ timeout });
|
|
758
913
|
await page.mouse.wheel(deltaX, deltaY);
|
|
759
914
|
} else {
|
|
760
915
|
await page.mouse.wheel(deltaX, deltaY);
|
|
@@ -868,8 +1023,8 @@ export class ActionExecutor extends EventEmitter {
|
|
|
868
1023
|
|
|
869
1024
|
let screenshot;
|
|
870
1025
|
if (options.selector) {
|
|
871
|
-
|
|
872
|
-
|
|
1026
|
+
screenshot = await this.elementLocator(page, options.selector)
|
|
1027
|
+
.screenshot({ ...screenshotOptions, timeout: this.actionTimeout(options) });
|
|
873
1028
|
} else {
|
|
874
1029
|
screenshot = await page.screenshot(screenshotOptions);
|
|
875
1030
|
}
|
|
@@ -884,6 +1039,31 @@ export class ActionExecutor extends EventEmitter {
|
|
|
884
1039
|
};
|
|
885
1040
|
}
|
|
886
1041
|
|
|
1042
|
+
/**
|
|
1043
|
+
* Navigate an existing page to a URL under the SSRF checks every load needs.
|
|
1044
|
+
* Used for the initial load and again before each chain retry.
|
|
1045
|
+
* @param {Page} page - Playwright page
|
|
1046
|
+
* @param {string} url - URL to navigate to
|
|
1047
|
+
* @returns {Promise<void>}
|
|
1048
|
+
*/
|
|
1049
|
+
async navigateToUrl(page, url) {
|
|
1050
|
+
// resolveDns:true because Playwright does its own DNS resolution, so
|
|
1051
|
+
// hostname-based checks alone would miss DNS-rebinding/private-IP targets.
|
|
1052
|
+
await assertUrlAllowed(url, { resolveDns: true });
|
|
1053
|
+
|
|
1054
|
+
await page.goto(url, {
|
|
1055
|
+
waitUntil: 'domcontentloaded',
|
|
1056
|
+
timeout: 30000
|
|
1057
|
+
});
|
|
1058
|
+
|
|
1059
|
+
// Re-validate the landed URL: a redirect during navigation could have
|
|
1060
|
+
// taken us into a blocked range even though the original URL was safe.
|
|
1061
|
+
const landedUrl = page.url();
|
|
1062
|
+
if (/^https?:\/\//i.test(landedUrl)) {
|
|
1063
|
+
await assertUrlAllowed(landedUrl, { resolveDns: true });
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
|
|
887
1067
|
/**
|
|
888
1068
|
* Initialize page with browser options (supports stealth mode)
|
|
889
1069
|
* @param {string} url - URL to navigate to
|
|
@@ -908,18 +1088,10 @@ export class ActionExecutor extends EventEmitter {
|
|
|
908
1088
|
await this.browserProcessor.stealthManager.initializeHumanBehaviorSimulator();
|
|
909
1089
|
}
|
|
910
1090
|
|
|
911
|
-
// Navigate to URL
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
});
|
|
916
|
-
|
|
917
|
-
// Re-validate the landed URL: a redirect during navigation could have
|
|
918
|
-
// taken us into a blocked range even though the original URL was safe.
|
|
919
|
-
const landedUrl = page.url();
|
|
920
|
-
if (/^https?:\/\//i.test(landedUrl)) {
|
|
921
|
-
await assertUrlAllowed(landedUrl, { resolveDns: true });
|
|
922
|
-
}
|
|
1091
|
+
// Navigate to URL. The pre-flight above repeats inside navigateToUrl —
|
|
1092
|
+
// that one is deliberately before page creation so a blocked URL never
|
|
1093
|
+
// launches a browser (tests/unit/phase1-ssrf-paths.test.js pins it).
|
|
1094
|
+
await this.navigateToUrl(page, url);
|
|
923
1095
|
|
|
924
1096
|
// Handle CloudFlare challenges and reCAPTCHA if stealth mode is enabled
|
|
925
1097
|
if (isStealth && this.browserProcessor.stealthManager) {
|
|
@@ -981,8 +1153,12 @@ export class ActionExecutor extends EventEmitter {
|
|
|
981
1153
|
*/
|
|
982
1154
|
async attemptErrorRecovery(page, action, error, executionContext) {
|
|
983
1155
|
const strategies = this.errorRecoveryStrategies.get(action.type) || [];
|
|
984
|
-
|
|
985
|
-
|
|
1156
|
+
// `retries` caps how many strategies get a turn. Walking all of them
|
|
1157
|
+
// unconditionally would add a second full round of timeouts to every action
|
|
1158
|
+
// that was never going to succeed.
|
|
1159
|
+
const budget = Math.max(0, action.retries ?? 1);
|
|
1160
|
+
|
|
1161
|
+
for (const strategy of strategies.slice(0, budget)) {
|
|
986
1162
|
try {
|
|
987
1163
|
this.log('info', 'Attempting error recovery with strategy: ' + strategy.name);
|
|
988
1164
|
const result = await strategy.recover(page, action, error, executionContext);
|
|
@@ -1010,20 +1186,21 @@ export class ActionExecutor extends EventEmitter {
|
|
|
1010
1186
|
this.errorRecoveryStrategies.set('click', [
|
|
1011
1187
|
{
|
|
1012
1188
|
name: 'waitAndRetry',
|
|
1013
|
-
recover: async (page, action
|
|
1189
|
+
recover: async (page, action) => {
|
|
1014
1190
|
await this.delay(1000);
|
|
1015
|
-
|
|
1016
|
-
|
|
1191
|
+
await this.elementLocator(page, action.selector)
|
|
1192
|
+
.click({ force: true, timeout: this.recoveryTimeout(action) });
|
|
1017
1193
|
return { success: true, data: { recovered: true, strategy: 'waitAndRetry' } };
|
|
1018
1194
|
}
|
|
1019
1195
|
},
|
|
1020
1196
|
{
|
|
1021
1197
|
name: 'scrollIntoView',
|
|
1022
|
-
recover: async (page, action
|
|
1023
|
-
const
|
|
1024
|
-
|
|
1198
|
+
recover: async (page, action) => {
|
|
1199
|
+
const timeout = this.recoveryTimeout(action);
|
|
1200
|
+
const locator = this.elementLocator(page, action.selector);
|
|
1201
|
+
await locator.scrollIntoViewIfNeeded({ timeout });
|
|
1025
1202
|
await this.delay(500);
|
|
1026
|
-
await
|
|
1203
|
+
await locator.click({ timeout });
|
|
1027
1204
|
return { success: true, data: { recovered: true, strategy: 'scrollIntoView' } };
|
|
1028
1205
|
}
|
|
1029
1206
|
}
|
|
@@ -1033,11 +1210,12 @@ export class ActionExecutor extends EventEmitter {
|
|
|
1033
1210
|
this.errorRecoveryStrategies.set('type', [
|
|
1034
1211
|
{
|
|
1035
1212
|
name: 'focusAndRetry',
|
|
1036
|
-
recover: async (page, action
|
|
1037
|
-
const
|
|
1038
|
-
|
|
1213
|
+
recover: async (page, action) => {
|
|
1214
|
+
const timeout = this.recoveryTimeout(action);
|
|
1215
|
+
const locator = this.elementLocator(page, action.selector);
|
|
1216
|
+
await locator.focus({ timeout });
|
|
1039
1217
|
await this.delay(500);
|
|
1040
|
-
await
|
|
1218
|
+
await locator.pressSequentially(action.text, { delay: action.delay, timeout });
|
|
1041
1219
|
return { success: true, data: { recovered: true, strategy: 'focusAndRetry' } };
|
|
1042
1220
|
}
|
|
1043
1221
|
}
|
|
@@ -1047,13 +1225,15 @@ export class ActionExecutor extends EventEmitter {
|
|
|
1047
1225
|
this.errorRecoveryStrategies.set('wait', [
|
|
1048
1226
|
{
|
|
1049
1227
|
name: 'extendTimeout',
|
|
1050
|
-
recover: async (page, action
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1228
|
+
recover: async (page, action) => {
|
|
1229
|
+
if (!action.selector) return { success: false };
|
|
1230
|
+
// One more bounded window, not a doubled one: the action has already
|
|
1231
|
+
// waited its full timeout, so doubling made a wait that could never
|
|
1232
|
+
// resolve cost 3x what the caller asked for.
|
|
1233
|
+
await this.waitForCondition(
|
|
1234
|
+
page, action.selector, action.condition, this.recoveryTimeout(action)
|
|
1235
|
+
);
|
|
1236
|
+
return { success: true, data: { recovered: true, strategy: 'extendTimeout' } };
|
|
1057
1237
|
}
|
|
1058
1238
|
}
|
|
1059
1239
|
]);
|
package/src/core/AuthManager.js
CHANGED