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.
Files changed (41) hide show
  1. package/CLAUDE.md +9 -6
  2. package/README.md +28 -8
  3. package/package.json +6 -5
  4. package/server.js +56 -16
  5. package/src/core/ActionExecutor.js +246 -66
  6. package/src/core/AuthManager.js +1 -0
  7. package/src/core/ChangeTracker.js +215 -22
  8. package/src/core/ResearchOrchestrator.js +9 -3
  9. package/src/core/SamplingClient.js +4 -5
  10. package/src/core/StealthBrowserManager.js +64 -18
  11. package/src/core/cache/CacheManager.js +7 -2
  12. package/src/core/crawlers/BFSCrawler.js +14 -6
  13. package/src/core/llm/LLMManager.js +61 -11
  14. package/src/core/llm/OllamaProvider.js +139 -0
  15. package/src/core/processing/BrowserProcessor.js +28 -2
  16. package/src/schemas/toolOutputSchemas.js +53 -1
  17. package/src/server/requestContext.js +26 -0
  18. package/src/server/transports/streamableHttp.js +54 -11
  19. package/src/server/withAuth.js +24 -6
  20. package/src/skills/agent-skills/crawlforge-deep-research/SKILL.md +26 -3
  21. package/src/skills/agent-skills/crawlforge-getting-started/SKILL.md +5 -4
  22. package/src/skills/agent-skills/crawlforge-getting-started/references/credits.md +1 -0
  23. package/src/skills/agent-skills/crawlforge-structured-extraction/SKILL.md +6 -4
  24. package/src/skills/agent-skills/crawlforge-structured-extraction/references/templates.md +2 -1
  25. package/src/tools/advanced/ScrapeWithActionsTool.js +4 -1
  26. package/src/tools/basic/_fetch.js +8 -2
  27. package/src/tools/basic/fetchUrl.js +4 -1
  28. package/src/tools/crawl/crawlDeep.js +19 -5
  29. package/src/tools/extract/extractStructured.js +16 -4
  30. package/src/tools/extract/extractWithLlm.js +80 -10
  31. package/src/tools/extract/listOllamaModels.js +4 -6
  32. package/src/tools/scrape/_brandingExtractor.js +1 -1
  33. package/src/tools/scrape/unifiedScrape.js +71 -5
  34. package/src/tools/search/adapters/redditOfficialApi.js +196 -0
  35. package/src/tools/search/redditNormalize.js +95 -0
  36. package/src/tools/search/redditSearch.js +326 -0
  37. package/src/tools/templates/ScrapeTemplateTool.js +8 -3
  38. package/src/utils/hiddenContent.js +330 -0
  39. package/src/utils/htmlToMarkdown.js +12 -2
  40. package/src/utils/ollamaConfig.js +121 -0
  41. 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
- retries: z.number().min(0).max(5).default(0),
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
- let timeout = action.timeout || this.defaultTimeout;
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 with timeout
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
- setTimeout(() => reject(new Error('Action timeout')), timeout);
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
- result = await Promise.race([executionPromise, timeoutPromise]);
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
- const options = {};
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 element = await page.waitForSelector(action.selector);
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 element.click(clickOptions);
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 element = await page.waitForSelector(action.selector);
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 element.selectText();
649
- await element.press('Delete');
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 element.type(action.text, { delay: action.delay });
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 keyOptions = {};
675
- if (action.modifiers.length > 0) {
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
- const element = await page.waitForSelector(action.selector);
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
- const element = await page.waitForSelector(action.toElement);
711
- await element.scrollIntoView();
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
- const element = await page.waitForSelector(action.selector);
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
- const element = await page.waitForSelector(options.selector);
872
- screenshot = await element.screenshot(screenshotOptions);
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
- await page.goto(url, {
913
- waitUntil: 'domcontentloaded',
914
- timeout: 30000
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
- for (const strategy of strategies) {
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, error) => {
1189
+ recover: async (page, action) => {
1014
1190
  await this.delay(1000);
1015
- const element = await page.waitForSelector(action.selector, { timeout: 5000 });
1016
- await element.click({ force: true });
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, error) => {
1023
- const element = await page.waitForSelector(action.selector);
1024
- await element.scrollIntoView();
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 element.click();
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, error) => {
1037
- const element = await page.waitForSelector(action.selector);
1038
- await element.focus();
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 element.type(action.text, { delay: action.delay });
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, error) => {
1051
- const extendedTimeout = (action.timeout || this.defaultTimeout) * 2;
1052
- if (action.selector) {
1053
- await page.waitForSelector(action.selector, { timeout: extendedTimeout });
1054
- return { success: true, data: { recovered: true, strategy: 'extendTimeout' } };
1055
- }
1056
- return { success: false };
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
  ]);
@@ -560,6 +560,7 @@ class AuthManager {
560
560
  process_document: 2,
561
561
  localization: 2,
562
562
  scrape: 2,
563
+ reddit_search: 2, // free community archives (Arctic Shift / PullPush), no external billing
563
564
 
564
565
  // 3 credits
565
566
  track_changes: 3,