crawlforge-mcp-server 6.4.0 → 6.6.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 (37) hide show
  1. package/CLAUDE.md +5 -5
  2. package/README.md +7 -6
  3. package/package.json +2 -1
  4. package/server.js +83 -15
  5. package/src/cli/commands/browser.js +77 -0
  6. package/src/cli/index.js +3 -1
  7. package/src/core/ActionExecutor.js +185 -7
  8. package/src/core/AuthManager.js +26 -0
  9. package/src/core/ChangeTracker.js +25 -8
  10. package/src/core/browser/SessionStore.js +331 -0
  11. package/src/core/browser/snapshot.js +346 -0
  12. package/src/core/llm/LLMManager.js +86 -6
  13. package/src/core/processing/PDFProcessor.js +3 -1
  14. package/src/server/fallbackHints.js +4 -0
  15. package/src/server/inlineThreshold.js +31 -1
  16. package/src/server/requestContext.js +26 -5
  17. package/src/server/toolFilter.js +2 -2
  18. package/src/server/transports/streamableHttp.js +38 -8
  19. package/src/skills/agent-skills/crawlforge-batch-automation/SKILL.md +9 -2
  20. package/src/skills/agent-skills/crawlforge-batch-automation/references/actions.md +50 -4
  21. package/src/skills/agent-skills/crawlforge-browser-sessions/SKILL.md +178 -0
  22. package/src/skills/agent-skills/crawlforge-getting-started/SKILL.md +7 -4
  23. package/src/skills/agent-skills/crawlforge-getting-started/references/cli.md +6 -1
  24. package/src/skills/agent-skills/crawlforge-getting-started/references/credits.md +4 -0
  25. package/src/skills/installer.js +1 -1
  26. package/src/tools/advanced/BrowserSessionTool.js +476 -0
  27. package/src/tools/advanced/ScrapeWithActionsTool.js +10 -1
  28. package/src/tools/crawl/mapSite.js +81 -7
  29. package/src/tools/extract/extractEmbeddedState.js +18 -2
  30. package/src/tools/extract/extractStructured.js +4 -0
  31. package/src/tools/extract/processDocument.js +94 -1
  32. package/src/tools/scrape/_brandingExtractor.js +23 -5
  33. package/src/tools/scrape/unifiedScrape.js +8 -1
  34. package/src/tools/search/redditSearch.js +24 -17
  35. package/src/utils/hiddenContent.js +67 -2
  36. package/src/utils/redditHosts.js +123 -0
  37. package/src/utils/robotsGate.js +27 -3
@@ -9,6 +9,7 @@ import { EventEmitter } from 'events';
9
9
  import { createHash } from 'node:crypto';
10
10
  import { assertUrlAllowed } from '../utils/ssrfGuard.js';
11
11
  import { browserPreflight } from '../utils/robotsGate.js';
12
+ import { isRef, resolveRef, captureSnapshot } from './browser/snapshot.js';
12
13
 
13
14
  // executeJavaScript hardening limits (only relevant when the deploy-time flag
14
15
  // ALLOW_JAVASCRIPT_EXECUTION=true is set; JS execution stays off by default).
@@ -148,6 +149,14 @@ const ExecuteJavaScriptActionSchema = BaseActionSchema.extend({
148
149
  returnResult: z.boolean().default(true)
149
150
  });
150
151
 
152
+ // camelCase (interactiveOnly/maxNodes) to match every other field in the same
153
+ // action object: clickCount, fullPage, toElement, captureAfter.
154
+ const SnapshotActionSchema = BaseActionSchema.extend({
155
+ type: z.literal('snapshot'),
156
+ interactiveOnly: z.boolean().default(true),
157
+ maxNodes: z.number().min(1).max(1000).optional()
158
+ });
159
+
151
160
  const ActionSchema = z.union([
152
161
  WaitActionSchema,
153
162
  ClickActionSchema,
@@ -158,7 +167,8 @@ const ActionSchema = z.union([
158
167
  HoverActionSchema,
159
168
  NavigateActionSchema,
160
169
  ScreenshotActionSchema,
161
- ExecuteJavaScriptActionSchema
170
+ ExecuteJavaScriptActionSchema,
171
+ SnapshotActionSchema
162
172
  ]);
163
173
 
164
174
  const ActionChainSchema = z.object({
@@ -314,7 +324,15 @@ export class ActionExecutor extends EventEmitter {
314
324
  fullPage: true,
315
325
  description: 'Error screenshot'
316
326
  });
317
- executionContext.screenshots.push(errorScreenshot);
327
+ // An actionId is what lets the server publish the shot as a
328
+ // crawlforge://screenshot/{actionId} resource and drop the base64
329
+ // from the result; without one a failed chain shipped 1.7 MB of
330
+ // PNG inline (R21, 2026-09-09).
331
+ executionContext.screenshots.push({
332
+ ...errorScreenshot,
333
+ actionId: this.generateActionId(),
334
+ error: true
335
+ });
318
336
  } catch (screenshotError) {
319
337
  this.log('warn', 'Failed to capture error screenshot: ' + screenshotError.message);
320
338
  }
@@ -507,6 +525,123 @@ export class ActionExecutor extends EventEmitter {
507
525
  throw lastError;
508
526
  }
509
527
 
528
+ /**
529
+ * Run actions against a page this executor does NOT own.
530
+ *
531
+ * executeActionChain() is the one-shot path and owns its page: it creates it,
532
+ * closes it in `finally`, and re-navigates to the chain's starting URL before
533
+ * each retry. None of that is right for a browser session, where the page
534
+ * outlives the call and "the starting URL" is wherever the last call left it —
535
+ * re-navigating there would throw away the very state the session exists to
536
+ * keep. So this is the same per-action loop with the lifecycle removed: it
537
+ * opens nothing, closes nothing, never re-navigates, and deliberately does not
538
+ * register in `activeChains` (destroy() closes the pages of everything in
539
+ * there, which would take a live session's page out from under its store).
540
+ *
541
+ * Gating comes for free and must not be duplicated by callers: a `navigate`
542
+ * action goes through executeNavigateAction, which re-runs the SSRF guard and
543
+ * the blocklist/robots gate on every hop (see the comment at its definition).
544
+ * `browserOptions.respectRobots` is what reaches that gate.
545
+ *
546
+ * No `finalHtml` here, unlike the chain: the page is still open afterwards, so
547
+ * content is read from it when it is asked for rather than on every call.
548
+ *
549
+ * @param {Page} page - a live page owned by the caller
550
+ * @param {Array} actions - actions to run, in order
551
+ * @param {Object} [options]
552
+ * @param {boolean} [options.continueOnError=false] - keep going past a failed action
553
+ * @param {number} [options.timeout] - per-action deadline for actions that name none
554
+ * @param {Object} [options.browserOptions] - carries `respectRobots` to the navigate gate
555
+ * @returns {Promise<Object>} Same results/screenshots/capturedStates shape the chain returns
556
+ */
557
+ async executeActionsOnPage(page, actions, { continueOnError = false, timeout, browserOptions = {} } = {}) {
558
+ const startTime = Date.now();
559
+ // executeActionInternal reads `id` for its events and `browserOptions` for
560
+ // the navigate action's robots gate; nothing else on a chain context is
561
+ // consulted from there.
562
+ const executionContext = {
563
+ id: this.generateChainId(),
564
+ url: page.url(),
565
+ browserOptions,
566
+ startTime,
567
+ results: [],
568
+ screenshots: [],
569
+ capturedStates: []
570
+ };
571
+
572
+ let success = true;
573
+ let error;
574
+
575
+ for (let i = 0; i < actions.length; i++) {
576
+ // actionTimeout() reads the deadline off the action itself, so a caller's
577
+ // per-call timeout is applied as the default for actions that set none.
578
+ const action = timeout && !actions[i].timeout ? { ...actions[i], timeout } : actions[i];
579
+
580
+ const actionResult = await this.executeActionInternal(page, action, executionContext);
581
+ executionContext.results.push(actionResult);
582
+ this.stats.totalActions++;
583
+
584
+ if (actionResult.success) {
585
+ this.stats.successfulActions++;
586
+ } else {
587
+ this.stats.failedActions++;
588
+ }
589
+
590
+ // Collection kept in step with executeChainWithRetries — a screenshot
591
+ // action's payload and a captureAfter snapshot of the page reach the
592
+ // caller in the same fields either way.
593
+ if (actionResult.success && action.type === 'screenshot' && actionResult.result?.data) {
594
+ executionContext.screenshots.push({
595
+ actionId: actionResult.id,
596
+ data: actionResult.result.data,
597
+ format: actionResult.result.format,
598
+ fullPage: actionResult.result.fullPage,
599
+ timestamp: actionResult.timestamp
600
+ });
601
+ }
602
+
603
+ if (action.captureAfter) {
604
+ try {
605
+ const capturedHtml = await page.content();
606
+ executionContext.capturedStates.push({
607
+ afterActionIndex: i,
608
+ afterActionId: actionResult.id,
609
+ url: page.url(),
610
+ html: capturedHtml,
611
+ timestamp: Date.now()
612
+ });
613
+ } catch (captureErr) {
614
+ this.log('warn', 'Failed to capture intermediate state: ' + captureErr.message);
615
+ }
616
+ }
617
+
618
+ if (!actionResult.success && !action.continueOnError && !continueOnError) {
619
+ success = false;
620
+ error = actionResult.error;
621
+ break;
622
+ }
623
+
624
+ if (i < actions.length - 1 && this.actionDelay > 0) {
625
+ await this.delay(this.actionDelay);
626
+ }
627
+ }
628
+
629
+ return {
630
+ success,
631
+ error,
632
+ finalUrl: page.url(),
633
+ executionTime: Date.now() - startTime,
634
+ results: executionContext.results,
635
+ screenshots: executionContext.screenshots,
636
+ capturedStates: executionContext.capturedStates,
637
+ stats: {
638
+ totalActions: executionContext.results.length,
639
+ successfulActions: executionContext.results.filter(r => r.success).length,
640
+ failedActions: executionContext.results.filter(r => !r.success).length
641
+ }
642
+ };
643
+ }
644
+
510
645
  /**
511
646
  * Execute individual action (original internal method)
512
647
  * @param {Page} page - Playwright page
@@ -627,6 +762,23 @@ export class ActionExecutor extends EventEmitter {
627
762
  return Math.min(this.actionTimeout(action), RECOVERY_TIMEOUT_MS);
628
763
  }
629
764
 
765
+ /**
766
+ * Resolve a caller-supplied selector.
767
+ *
768
+ * A selector starting with `@` names a ref a prior snapshot action assigned,
769
+ * and resolves to the attribute selector that snapshot stamped on the
770
+ * element; anything else is already a CSS selector. Every selector a caller
771
+ * writes goes through here, so refs work in every action type without any
772
+ * schema change — and, because a ref resolves to an ordinary CSS selector,
773
+ * in the stealth human-behaviour paths too.
774
+ * @param {Page} page - Playwright page
775
+ * @param {string} selector - CSS selector, or an `@e1` snapshot ref
776
+ * @returns {string} CSS selector
777
+ */
778
+ resolveSelector(page, selector) {
779
+ return isRef(selector) ? resolveRef(page, selector) : selector;
780
+ }
781
+
630
782
  /**
631
783
  * Locator for an action's selector.
632
784
  *
@@ -635,11 +787,11 @@ export class ActionExecutor extends EventEmitter {
635
787
  * selector matching more than one element, which would break action chains
636
788
  * that work today.
637
789
  * @param {Page} page - Playwright page
638
- * @param {string} selector - CSS/text selector
790
+ * @param {string} selector - CSS/text selector, or an `@e1` snapshot ref
639
791
  * @returns {Locator} Playwright locator
640
792
  */
641
793
  elementLocator(page, selector) {
642
- return page.locator(selector).first();
794
+ return page.locator(this.resolveSelector(page, selector)).first();
643
795
  }
644
796
 
645
797
  /**
@@ -725,6 +877,8 @@ export class ActionExecutor extends EventEmitter {
725
877
  return await this.executeScreenshotAction(page, action);
726
878
  case 'executeJavaScript':
727
879
  return await this.executeJavaScriptAction(page, action);
880
+ case 'snapshot':
881
+ return await this.executeSnapshotAction(page, action);
728
882
  default:
729
883
  throw new Error('Unknown action type: ' + action.type);
730
884
  }
@@ -784,7 +938,9 @@ export class ActionExecutor extends EventEmitter {
784
938
  // be there before it starts (locator.click() would have waited for it).
785
939
  await locator.waitFor({ state: 'visible', timeout });
786
940
  // Use human-like clicking behavior
787
- await humanBehaviorSimulator.simulateClick(page, action.selector, {
941
+ // The simulator takes a raw selector string rather than a locator, so
942
+ // it needs the resolved form — it never goes through elementLocator.
943
+ await humanBehaviorSimulator.simulateClick(page, this.resolveSelector(page, action.selector), {
788
944
  button: action.button,
789
945
  clickCount: action.clickCount,
790
946
  delay: action.delay,
@@ -841,7 +997,7 @@ export class ActionExecutor extends EventEmitter {
841
997
  // Same as click: the simulator works from the selector, so wait first.
842
998
  await locator.waitFor({ state: 'visible', timeout });
843
999
  // Use human-like typing behavior
844
- await humanBehaviorSimulator.simulateTyping(page, action.selector, action.text);
1000
+ await humanBehaviorSimulator.simulateTyping(page, this.resolveSelector(page, action.selector), action.text);
845
1001
  } else {
846
1002
  // Standard typing behavior
847
1003
  await locator.pressSequentially(action.text, { delay: action.delay, timeout });
@@ -899,7 +1055,7 @@ export class ActionExecutor extends EventEmitter {
899
1055
  if (humanBehaviorSimulator) {
900
1056
  // Use human-like scrolling to element
901
1057
  await humanBehaviorSimulator.simulateScroll(page, {
902
- target: action.toElement
1058
+ target: this.resolveSelector(page, action.toElement)
903
1059
  });
904
1060
  } else {
905
1061
  // scrollIntoViewIfNeeded, not scrollIntoView — the latter is a DOM API
@@ -1122,6 +1278,21 @@ export class ActionExecutor extends EventEmitter {
1122
1278
  result: action.returnResult ? result : undefined
1123
1279
  };
1124
1280
  }
1281
+
1282
+ /**
1283
+ * Execute snapshot action - the page's interactive elements, each stamped
1284
+ * with a ref later actions can target instead of a guessed CSS selector.
1285
+ * @param {Page} page - Playwright page
1286
+ * @param {Object} action - Snapshot action
1287
+ * @returns {Promise<Object>} Snapshot tree with refs
1288
+ */
1289
+ async executeSnapshotAction(page, action) {
1290
+ return await captureSnapshot(page, {
1291
+ interactiveOnly: action.interactiveOnly,
1292
+ maxNodes: action.maxNodes
1293
+ });
1294
+ }
1295
+
1125
1296
  /**
1126
1297
  * Capture screenshot
1127
1298
  * @param {Page} page - Playwright page
@@ -1313,6 +1484,13 @@ export class ActionExecutor extends EventEmitter {
1313
1484
  * @returns {Promise<Object>} Recovery result
1314
1485
  */
1315
1486
  async attemptErrorRecovery(page, action, error, executionContext) {
1487
+ // A stale ref names an element that went away with the old document, so no
1488
+ // strategy here can find it again. Re-snapshotting is the caller's job:
1489
+ // fail the action now rather than spend the recovery budget on it.
1490
+ if (error?.name === 'StaleRefError') {
1491
+ return { success: false };
1492
+ }
1493
+
1316
1494
  const strategies = this.errorRecoveryStrategies.get(action.type) || [];
1317
1495
  // `retries` caps how many strategies get a turn. Walking all of them
1318
1496
  // unconditionally would add a second full round of timeouts to every action
@@ -593,6 +593,7 @@ class AuthManager {
593
593
  analyze_content: 3,
594
594
  extract_structured: 3,
595
595
  extract_with_llm: 3,
596
+ browser_session: 3, // ceiling — `open`'s price; the schedule is per operation below
596
597
 
597
598
  // 4 credits
598
599
  summarize_content: 4,
@@ -635,6 +636,28 @@ class AuthManager {
635
636
  if (bookkeepingOps.has(params?.operation)) return 1;
636
637
  }
637
638
 
639
+ // browser_session bills per operation for the same reason: one session is
640
+ // many calls, and a flat price would charge the ceiling for every cheap
641
+ // one. `open` launches a browser and navigates, so it costs more than a
642
+ // `scrape` (2); `read` extracts content and is priced with `scrape`;
643
+ // `snapshot`, `act` and `screenshot` are one injected script or one action
644
+ // batch against a page that is already open. `close` and `list` are
645
+ // bookkeeping but still cost 1 — nothing here runs for free. An unknown or
646
+ // absent operation falls through to the flat 3: the published price is the
647
+ // ceiling, never the floor.
648
+ if (tool === 'browser_session') {
649
+ const operationCosts = new Map([
650
+ ['open', 3],
651
+ ['snapshot', 1],
652
+ ['act', 1],
653
+ ['read', 2],
654
+ ['screenshot', 1],
655
+ ['close', 1],
656
+ ['list', 1]
657
+ ]);
658
+ if (operationCosts.has(params?.operation)) return operationCosts.get(params.operation);
659
+ }
660
+
638
661
  // localize_search with a query runs a real web search through the same
639
662
  // adapter search_web uses, so it is priced as one rather than undercutting it.
640
663
  if (tool === 'localization' && params?.operation === 'localize_search' && params?.searchParams?.query) {
@@ -719,6 +742,9 @@ class AuthManager {
719
742
  ? 'Bookkeeping operation — launches no browser.'
720
743
  : 'Browser operation. configure/enable/disable/get_stats/cleanup cost 1 credit each.';
721
744
  break;
745
+ case 'browser_session':
746
+ note = `Priced per operation: open 3, read 2, snapshot/act/screenshot/close/list 1. This call bills ${projected}.`;
747
+ break;
722
748
  case 'serp_rank':
723
749
  note = projected === 0
724
750
  ? 'DataForSEO not configured — no-op, no credits charged. Set DATAFORSEO_LOGIN/PASSWORD to enable.'
@@ -720,14 +720,16 @@ export class ChangeTracker extends EventEmitter {
720
720
  significanceScore += Math.min(totalElements * 0.05, 1) *
721
721
  (weights.additions + weights.removals + weights.modifications);
722
722
 
723
- // Text changes impact
724
- if (changeAnalysis.textChanges.length > 0) {
725
- const textChangeRatio = changeAnalysis.textChanges.reduce(
726
- (sum, change) => sum + (change.added?.length || 0) + (change.removed?.length || 0),
727
- 0
728
- ) / 1000; // Normalize by character count
729
-
730
- significanceScore += Math.min(textChangeRatio, 1) * weights.textChanges;
723
+ // Text changes impact. textChanges holds diff GROUPS — {type:'word_diff',
724
+ // changes:[{added, removed, value}]} — not flat parts, so reading
725
+ // `change.added.length` off a group was always 0 and this term never
726
+ // fired: a feed that grew by a whole record scored on similarity alone.
727
+ // The USGS all-hour earthquake feed gained an event (622 words, 84%
728
+ // similar) and compare reported hasChanges:false, "No significant
729
+ // changes detected" (R21, 2026-09-09).
730
+ const changedChars = this.changedTextChars(changeAnalysis.textChanges);
731
+ if (changedChars > 0) {
732
+ significanceScore += Math.min(changedChars / 1000, 1) * weights.textChanges;
731
733
  }
732
734
 
733
735
  // Determine significance level
@@ -1317,6 +1319,21 @@ export class ChangeTracker extends EventEmitter {
1317
1319
  );
1318
1320
  }
1319
1321
 
1322
+ /**
1323
+ * Characters added or removed at the text level, for significance scoring.
1324
+ * Same word-diff-then-line-diff choice as countTextChanges, and for the
1325
+ * same reason: the two describe one edit.
1326
+ */
1327
+ changedTextChars(textChanges = []) {
1328
+ const diff = textChanges.find(c => c.type === 'word_diff')
1329
+ || textChanges.find(c => c.type === 'line_diff');
1330
+ if (!diff) return 0;
1331
+ return diff.changes.reduce(
1332
+ (chars, part) => chars + ((part.added || part.removed) && typeof part.value === 'string' ? part.value.length : 0),
1333
+ 0
1334
+ );
1335
+ }
1336
+
1320
1337
  generateChangeSummary(changeAnalysis, significance) {
1321
1338
  const { addedElements, removedElements, modifiedElements, similarity } = changeAnalysis;
1322
1339