crawlforge-mcp-server 6.12.1 → 6.14.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "crawlforge-mcp-server",
3
- "version": "6.12.1",
3
+ "version": "6.14.0",
4
4
  "mcpName": "io.github.mysleekdesigns/crawlforge-mcp-server",
5
5
  "description": "CrawlForge MCP Server - Professional Model Context Protocol server with 31 web scraping, crawling, deep-research, and autonomous-extraction tools. Returns clean Markdown and structured JSON for Claude, Cursor, and any MCP client. Defaults to local Ollama for LLM extraction (no API key needed); OpenAI/Anthropic available as opt-in. Includes a unified multi-format scrape tool, an autonomous agent, pre-built site templates, and Camoufox stealth browsing.",
6
6
  "main": "server.js",
@@ -113,6 +113,7 @@
113
113
  "package.json"
114
114
  ],
115
115
  "dependencies": {
116
+ "@duckduckgo/autoconsent": "^16.43.0",
116
117
  "@modelcontextprotocol/core": "^2.0.0",
117
118
  "@modelcontextprotocol/node": "^2.0.0",
118
119
  "@modelcontextprotocol/server": "^2.0.0",
package/server.js CHANGED
@@ -111,7 +111,7 @@ if (configErrors.length > 0 && config.server.nodeEnv === 'production') {
111
111
  // Create the server
112
112
  const server = new McpServer({
113
113
  name: "crawlforge",
114
- version: "6.12.1",
114
+ version: "6.14.0",
115
115
  description: "Production-ready MCP server with 31 web scraping, crawling, and content processing tools. Features MCP Resources (crawlforge://), Prompts, Sampling fallback, Elicitation, stealth browsing, stateful browser sessions with element refs, deep research, structured extraction, embedded JavaScript state extraction, real Google SERP rank tracking, Reddit search via community archives, change tracking, local-LLM extraction via Ollama, unified multi-format scrape, and autonomous agent tool.",
116
116
  homepage: "https://www.crawlforge.dev",
117
117
  icon: "https://www.crawlforge.dev/icon.png",
@@ -941,13 +941,13 @@ registerToolIfEnabled("read_result", {
941
941
 
942
942
  // Tool: scrape_with_actions
943
943
  registerToolIfEnabled("scrape_with_actions", {
944
- description: "Use this when you must interact with a page before scraping - login, click buttons, fill forms, scroll, or wait for dynamic content to load - for SPAs, login-gated content, or multi-step flows. Actions: snapshot, wait, click, type, press, scroll, screenshot, executeJavaScript (disabled unless the server runs with ALLOW_JAVASCRIPT_EXECUTION=true; refused on the hosted API), select (dropdowns), hover, navigate. Start a chain with {type:\"snapshot\"} to list the page's interactive elements with stable refs (@e1, @e2 ...), then target those refs in later actions instead of guessing CSS selectors; navigation invalidates refs, so snapshot again after one. Set browserOptions.stealth:true to run the chain in the stealth browser, and browserOptions.engine to pick its engine (\"auto\" by default - camoufox when it is installed, Chromium otherwise, and the result says which ran). robots.txt is respected on every navigation. Screenshots from this tool are stored as crawlforge://screenshot/{actionId} resources. Not for pages that render without interaction (scrape) and not as the first attempt on a blocked site (stealth_mode operation:\"scrape\"). Cost: 5 credits. Example: scrape_with_actions({url: \"https://app.com/dashboard\", actions: [{type:\"snapshot\"},{type:\"type\",selector:\"@e2\",text:\"user@a.com\"},{type:\"click\",selector:\"@e4\"}]})",
944
+ description: "Use this when you must interact with a page before scraping - login, click buttons, fill forms, scroll, or wait for dynamic content to load - for SPAs, login-gated content, or multi-step flows. Actions: snapshot, wait, click, type, press, scroll, screenshot, executeJavaScript (disabled unless the server runs with ALLOW_JAVASCRIPT_EXECUTION=true; refused on the hosted API), select (dropdowns), hover, navigate. Start a chain with {type:\"snapshot\"} to list the page's interactive elements, including those in open shadow roots and iframes, with stable refs (@e1, @e2 ... in document order), then target those refs in later actions instead of guessing CSS selectors; navigation invalidates refs, so snapshot again after one. Set browserOptions.consent:\"reject\" (or \"accept\") to answer a cookie/consent banner before the first action; it is off by default. Set browserOptions.stealth:true to run the chain in the stealth browser, and browserOptions.engine to pick its engine (\"auto\" by default - camoufox when it is installed, Chromium otherwise, and the result says which ran). robots.txt is respected on every navigation. Screenshots from this tool are stored as crawlforge://screenshot/{actionId} resources. Not for pages that render without interaction (scrape) and not as the first attempt on a blocked site (stealth_mode operation:\"scrape\"). Cost: 5 credits. Example: scrape_with_actions({url: \"https://app.com/dashboard\", actions: [{type:\"snapshot\"},{type:\"type\",selector:\"@e2\",text:\"user@a.com\"},{type:\"click\",selector:\"@e4\"}]})",
945
945
  annotations: { title: "Scrape with Browser Actions", readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: true },
946
946
  inputSchema: {
947
947
  url: z.string().url().describe("The URL to scrape"),
948
948
  actions: z.array(z.object({
949
949
  type: z.enum(['snapshot', 'wait', 'click', 'type', 'press', 'scroll', 'screenshot', 'executeJavaScript', 'select', 'hover', 'navigate']).describe("executeJavaScript is disabled unless the server runs with ALLOW_JAVASCRIPT_EXECUTION=true; on the hosted API it is refused."),
950
- selector: z.string().optional().describe("A CSS selector, or a @e1 ref from an earlier snapshot action in this chain"),
950
+ selector: z.string().optional().describe("A CSS selector, or a ref from an earlier snapshot action in this chain (@e1, including elements inside shadow roots and iframes)"),
951
951
  text: z.string().optional(),
952
952
  key: z.string().optional(),
953
953
  script: z.string().optional(),
@@ -1014,7 +1014,8 @@ registerToolIfEnabled("scrape_with_actions", {
1014
1014
  viewportHeight: z.number().min(600).max(1080).default(720),
1015
1015
  timeout: z.number().min(10000).max(120000).default(30000),
1016
1016
  stealth: z.boolean().default(false).describe("Run the action chain in the stealth browser (randomized fingerprint, WebRTC/canvas spoofing) instead of the standard browser pool. Renders JavaScript; it does not solve challenges."),
1017
- engine: z.enum(["auto", "chromium", "camoufox", "playwright"]).default("auto").describe("Stealth engine for the chain, with stealth:true. \"auto\" (default) runs camoufox when it is installed and Chromium otherwise; the result's `engine` says which ran. \"camoufox\" is Firefox-based with a higher anti-detect score; \"chromium\" (= \"playwright\") forces Chromium. Refused without stealth:true, where the browser is always Chromium.")
1017
+ engine: z.enum(["auto", "chromium", "camoufox", "playwright"]).default("auto").describe("Stealth engine for the chain, with stealth:true. \"auto\" (default) runs camoufox when it is installed and Chromium otherwise; the result's `engine` says which ran. \"camoufox\" is Firefox-based with a higher anti-detect score; \"chromium\" (= \"playwright\") forces Chromium. Refused without stealth:true, where the browser is always Chromium."),
1018
+ consent: z.enum(["off", "reject", "accept"]).default("off").describe("Cookie/consent banner handling, using DuckDuckGo autoconsent's rules for known consent platforms (OneTrust, Sourcepoint, Didomi ...). \"reject\" declines non-essential cookies and \"accept\" accepts them, once after the initial page load and again after each navigate action, for at most 2s each; the result's `consent` ({cmp, action, ms}) and each navigate result say what was found and done. A banner no rule matches is left in place (action \"none\") and never fails the chain. Default \"off\": the page is left as it loads, so a chain that clicks the banner itself works unchanged.")
1018
1019
  }).optional().describe("Browser configuration options"),
1019
1020
  extractionOptions: z.object({
1020
1021
  selectors: z.record(z.string()).optional(),
@@ -1051,7 +1052,7 @@ registerToolIfEnabled("scrape_with_actions", {
1051
1052
 
1052
1053
  // Tool: browser_session
1053
1054
  registerToolIfEnabled("browser_session", {
1054
- description: "Use this to drive a browser across several calls, keeping the page, its cookies and its login in between. The loop is: open a session on a URL, snapshot it to list the interactive elements with stable refs (@e1, @e2 ...), act on those refs, read the content, close. Because the page stays open you can look before each step instead of committing to a whole chain up front, so a wrong selector costs one call rather than all of them. Operations: open (url, stealth, engine, ttl, activity_ttl, viewport), snapshot, act (the same action array as scrape_with_actions), read (formats), screenshot, close, list. Navigation invalidates refs, so snapshot again after one. robots.txt is respected on every navigation, and screenshots are stored as crawlforge://screenshot/{actionId} resources. A session expires 600s after it opens or 300s after its last use, whichever comes first, so close it when you are done. Not for a page that renders without interaction (scrape), and not for an interaction you can write out in advance - that is one scrape_with_actions call for 5. Cost: 3 credits to open; read 2; snapshot, act, screenshot, close and list 1 each. Example: browser_session({operation:\"open\", url:\"https://app.com/login\"}), then browser_session({operation:\"snapshot\", session_id:\"...\"})",
1055
+ description: "Use this to drive a browser across several calls, keeping the page, its cookies and its login in between. The loop is: open a session on a URL, snapshot it to list the interactive elements with stable refs (@e1, @e2 ..., including elements inside open shadow roots and iframes), act on those refs, read the content, close. Because the page stays open you can look before each step instead of committing to a whole chain up front, so a wrong selector costs one call rather than all of them. Operations: open (url, stealth, engine, ttl, activity_ttl, viewport), snapshot, act (the same action array as scrape_with_actions), read (formats), screenshot, close, list. Navigation invalidates refs, so snapshot again after one. robots.txt is respected on every navigation, and screenshots are stored as crawlforge://screenshot/{actionId} resources. A session expires 600s after it opens or 300s after its last use, whichever comes first, so close it when you are done. Not for a page that renders without interaction (scrape), and not for an interaction you can write out in advance - that is one scrape_with_actions call for 5. Cost: 3 credits to open; read 2; snapshot, act, screenshot, close and list 1 each. Example: browser_session({operation:\"open\", url:\"https://app.com/login\"}), then browser_session({operation:\"snapshot\", session_id:\"...\"})",
1055
1056
  annotations: { title: "Browser Session", readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
1056
1057
  inputSchema: {
1057
1058
  operation: z.enum(["open", "snapshot", "act", "read", "screenshot", "close", "list"]).describe("open a session, observe it, act on it, read it, or close it"),
@@ -10,6 +10,8 @@ import { createHash } from 'node:crypto';
10
10
  import { assertUrlAllowed } from '../utils/ssrfGuard.js';
11
11
  import { browserPreflight } 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,6 +305,13 @@ 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
 
@@ -408,6 +417,7 @@ export class ActionExecutor extends EventEmitter {
408
417
  finalUrl: executionContext.finalUrl || url,
409
418
  finalHtml: executionContext.finalHtml,
410
419
  navigationStatus: executionContext.page?.__crawlforgeNavigation?.status ?? null,
420
+ consent: executionContext.consent,
411
421
  executionTime: Date.now() - startTime,
412
422
  results: executionContext.results,
413
423
  attempt: executionContext.attempt,
@@ -799,14 +809,15 @@ export class ActionExecutor extends EventEmitter {
799
809
  * Resolve a caller-supplied selector.
800
810
  *
801
811
  * A selector starting with `@` names a ref a prior snapshot action assigned,
802
- * and resolves to the attribute selector that snapshot stamped on the
803
- * element; anything else is already a CSS selector. Every selector a caller
812
+ * and resolves to the Playwright selector that snapshot recorded for it
813
+ * (`aria-ref=…` from the native snapshot, `[data-cf-ref=…]` from the walk
814
+ * fallback); anything else is already a CSS selector. Every selector a caller
804
815
  * writes goes through here, so refs work in every action type without any
805
- * schema change — and, because a ref resolves to an ordinary CSS selector,
806
- * in the stealth human-behaviour paths too.
816
+ * schema change — including the stealth human-behaviour paths, which take
817
+ * Playwright selectors.
807
818
  * @param {Page} page - Playwright page
808
819
  * @param {string} selector - CSS selector, or an `@e1` snapshot ref
809
- * @returns {string} CSS selector
820
+ * @returns {string} Playwright selector
810
821
  */
811
822
  resolveSelector(page, selector) {
812
823
  return isRef(selector) ? resolveRef(page, selector) : selector;
@@ -862,23 +873,25 @@ export class ActionExecutor extends EventEmitter {
862
873
  }
863
874
 
864
875
  /**
865
- * Give a navigation started by the action that just ran a chance to commit.
876
+ * Let the page settle after the action that just ran — a navigation commit
877
+ * and load, or an SPA re-render that fires no load event at all.
866
878
  *
867
879
  * Playwright auto-waits on the element it acts on, but nothing waits on the
868
- * *document* a click or keypress may have replaced, so the next action could
869
- * run against the outgoing page. A page that never navigated is already past
870
- * this state and returns immediately; a page that doesn't settle in time is
871
- * not itself an action failure, hence the catch.
880
+ * *document* a click or keypress may have changed, so the next action could
881
+ * run against the outgoing page. settlePage waits for `load` and then a quiet
882
+ * window (no DOM mutations, no requests in flight), capped and never longer
883
+ * than what is left of the action's `timeout`; a page that doesn't settle in
884
+ * time is not itself an action failure, so it never throws.
872
885
  * @param {Page} page - Playwright page
873
- * @param {number} timeout - Timeout in ms
874
- * @returns {Promise<void>}
886
+ * @param {number} timeout - The action's timeout in ms
887
+ * @param {number} startedAt - When the action started (Date.now()); the settle
888
+ * gets only the remainder, so a slow element wait plus a busy page cannot
889
+ * outrun the backstop and turn a successful action into a timeout
890
+ * @returns {Promise<{waited_ms: number, settled_by: string}>}
875
891
  */
876
- async settleAfterInteraction(page, timeout) {
877
- try {
878
- await page.waitForLoadState('domcontentloaded', { timeout });
879
- } catch {
880
- // Ignored on purpose — see above.
881
- }
892
+ async settleAfterInteraction(page, timeout, startedAt) {
893
+ const remaining = Math.max(1, timeout - (Date.now() - startedAt));
894
+ return await settlePage(page, { timeout: remaining });
882
895
  }
883
896
 
884
897
  /**
@@ -960,6 +973,7 @@ export class ActionExecutor extends EventEmitter {
960
973
  * @returns {Promise<Object>} Click result
961
974
  */
962
975
  async executeClickAction(page, action) {
976
+ const startedAt = Date.now();
963
977
  const timeout = this.actionTimeout(action);
964
978
  const locator = this.elementLocator(page, action.selector);
965
979
 
@@ -977,7 +991,8 @@ export class ActionExecutor extends EventEmitter {
977
991
  button: action.button,
978
992
  clickCount: action.clickCount,
979
993
  delay: action.delay,
980
- force: action.force
994
+ force: action.force,
995
+ timeout
981
996
  });
982
997
  } else {
983
998
  // Standard click behavior
@@ -998,7 +1013,7 @@ export class ActionExecutor extends EventEmitter {
998
1013
 
999
1014
  // A click can follow a link or submit a form; let that navigation commit
1000
1015
  // before the next action runs against the outgoing document.
1001
- await this.settleAfterInteraction(page, timeout);
1016
+ await this.settleAfterInteraction(page, timeout, startedAt);
1002
1017
 
1003
1018
  return {
1004
1019
  selector: action.selector,
@@ -1050,6 +1065,7 @@ export class ActionExecutor extends EventEmitter {
1050
1065
  * @returns {Promise<Object>} Press result
1051
1066
  */
1052
1067
  async executePressAction(page, action) {
1068
+ const startedAt = Date.now();
1053
1069
  const timeout = this.actionTimeout(action);
1054
1070
  const keyOptions = { timeout };
1055
1071
  if (action.modifiers?.length > 0) {
@@ -1063,7 +1079,7 @@ export class ActionExecutor extends EventEmitter {
1063
1079
  }
1064
1080
 
1065
1081
  // Enter on a form field navigates as often as a click does.
1066
- await this.settleAfterInteraction(page, timeout);
1082
+ await this.settleAfterInteraction(page, timeout, startedAt);
1067
1083
 
1068
1084
  return {
1069
1085
  key: action.key,
@@ -1161,6 +1177,7 @@ export class ActionExecutor extends EventEmitter {
1161
1177
  * @returns {Promise<Object>} Select result
1162
1178
  */
1163
1179
  async executeSelectAction(page, action) {
1180
+ const startedAt = Date.now();
1164
1181
  const timeout = this.actionTimeout(action);
1165
1182
  const values = action.values?.length ? action.values : [action.value];
1166
1183
 
@@ -1169,7 +1186,7 @@ export class ActionExecutor extends EventEmitter {
1169
1186
 
1170
1187
  // Faceted-search dropdowns commonly submit the form on change, so let any
1171
1188
  // navigation commit before the next action runs.
1172
- await this.settleAfterInteraction(page, timeout);
1189
+ await this.settleAfterInteraction(page, timeout, startedAt);
1173
1190
 
1174
1191
  return {
1175
1192
  selector: action.selector,
@@ -1214,6 +1231,7 @@ export class ActionExecutor extends EventEmitter {
1214
1231
  * @returns {Promise<Object>} Navigate result
1215
1232
  */
1216
1233
  async executeNavigateAction(page, action, executionContext) {
1234
+ const startedAt = Date.now();
1217
1235
  const timeout = this.actionTimeout(action);
1218
1236
 
1219
1237
  await assertUrlAllowed(action.url, { resolveDns: true });
@@ -1225,10 +1243,18 @@ export class ActionExecutor extends EventEmitter {
1225
1243
  });
1226
1244
  page.__crawlforgeGateWarnings = gateWarnings;
1227
1245
 
1246
+ // Consent handling gets at most what is left of the action's deadline, so
1247
+ // it cannot push a navigation that succeeded past the backstop.
1248
+ const consentMode = executionContext?.browserOptions?.consent;
1249
+ const consent = consentMode && consentMode !== 'off'
1250
+ ? await handleConsent(page, consentMode, { timeout: Math.min(2000, timeout - (Date.now() - startedAt)) })
1251
+ : undefined;
1252
+
1228
1253
  return {
1229
1254
  url: action.url,
1230
1255
  finalUrl: page.url(),
1231
- waitUntil: action.waitUntil || 'domcontentloaded'
1256
+ waitUntil: action.waitUntil || 'domcontentloaded',
1257
+ ...(consent ? { consent } : {})
1232
1258
  };
1233
1259
  }
1234
1260
 
@@ -1314,8 +1340,8 @@ export class ActionExecutor extends EventEmitter {
1314
1340
  }
1315
1341
 
1316
1342
  /**
1317
- * Execute snapshot action - the page's interactive elements, each stamped
1318
- * with a ref later actions can target instead of a guessed CSS selector.
1343
+ * Execute snapshot action - the page's interactive elements, each carrying
1344
+ * a ref later actions can target instead of a guessed CSS selector.
1319
1345
  * @param {Page} page - Playwright page
1320
1346
  * @param {Object} action - Snapshot action
1321
1347
  * @returns {Promise<Object>} Snapshot tree with refs
@@ -1323,7 +1349,9 @@ export class ActionExecutor extends EventEmitter {
1323
1349
  async executeSnapshotAction(page, action) {
1324
1350
  return await captureSnapshot(page, {
1325
1351
  interactiveOnly: action.interactiveOnly,
1326
- maxNodes: action.maxNodes
1352
+ maxNodes: action.maxNodes,
1353
+ // The render wait before the walk stays inside the action's deadline.
1354
+ timeout: this.actionTimeout(action)
1327
1355
  });
1328
1356
  }
1329
1357
 
@@ -3638,8 +3638,10 @@ export class CamoufoxAdapter extends BrowserEngine {
3638
3638
  // `blockWebRTC: false` is the stealthier setting behind a proxy.
3639
3639
  if (config.blockWebRTC) options.block_webrtc = true;
3640
3640
  // Native cursor humanization: camoufox moves the pointer along a plausible
3641
- // path rather than teleporting it.
3642
- if (config.humanize) options.humanize = true;
3641
+ // path rather than teleporting it. The number caps one move in seconds;
3642
+ // `true` means camoufox's 1.5 s default, which made a single stealth click
3643
+ // cost 2 s or more of cursor travel alone.
3644
+ if (config.humanize) options.humanize = 0.5;
3643
3645
  // Pin the persona to the installed binary's Firefox major so the UA's two
3644
3646
  // version tokens agree. Left unpinned, camoufox's own version rewrite
3645
3647
  // reaches only `rv:` and about half of all launches announce a Firefox
@@ -0,0 +1,81 @@
1
+ /**
2
+ * actionQueue — the one process-wide budget of browser slots that
3
+ * `scrape_with_actions` chains and `browser_session` opens share.
4
+ *
5
+ * A caller over the limit WAITS for a slot instead of being refused: the live
6
+ * review's fourth parallel scrape_with_actions call was rejected outright with
7
+ * "Maximum concurrent sessions (3) reached". It now queues for up to
8
+ * CRAWLFORGE_ACTION_QUEUE_TIMEOUT_MS and only then fails, with a message that
9
+ * names the queue depth and the limit.
10
+ *
11
+ * A slot covers the slow part of a browser job — context/page creation and
12
+ * navigation — not the life of whatever it produced. browser_session takes one
13
+ * for `open` only (see BrowserSessionTool.openSession); its session caps live
14
+ * in SessionStore and are policy, which still refuses.
15
+ */
16
+
17
+ import PQueue from 'p-queue';
18
+
19
+ const DEFAULT_CONCURRENCY = 3;
20
+ const DEFAULT_TIMEOUT_MS = 60_000;
21
+
22
+ /** A positive integer from the environment, or the default for anything else. */
23
+ function positiveInt(value, fallback) {
24
+ const n = Number.parseInt(value, 10);
25
+ return Number.isInteger(n) && n > 0 ? n : fallback;
26
+ }
27
+
28
+ /**
29
+ * @param {Object} [opts]
30
+ * @param {number} [opts.concurrency] — slots that may run at once
31
+ * @param {number} [opts.timeoutMs] — longest a caller waits for a slot
32
+ */
33
+ export function createActionQueue({
34
+ concurrency = positiveInt(process.env.CRAWLFORGE_MAX_ACTION_SESSIONS, DEFAULT_CONCURRENCY),
35
+ timeoutMs = positiveInt(process.env.CRAWLFORGE_ACTION_QUEUE_TIMEOUT_MS, DEFAULT_TIMEOUT_MS)
36
+ } = {}) {
37
+ const queue = new PQueue({ concurrency });
38
+
39
+ /**
40
+ * Run `fn` once a slot is free, holding the slot until `fn` settles.
41
+ *
42
+ * The timeout bounds the WAIT, never the run: the timer is cleared the moment
43
+ * the job starts. That matters because p-queue races a running job against
44
+ * its signal and frees the slot on abort while `fn` carries on — an abort
45
+ * after start would let a fourth browser job in beside three live ones.
46
+ * Before start, the abort removes the job from the queue, so a caller that
47
+ * timed out never runs later.
48
+ */
49
+ function run(fn, { label = 'browser job' } = {}) {
50
+ const controller = new AbortController();
51
+ const timer = setTimeout(() => {
52
+ // Read before the abort removes this job: the depth the caller waited behind.
53
+ controller.abort(new Error(
54
+ `${label} timed out after ${timeoutMs / 1000} s waiting for a browser slot: ` +
55
+ `${queue.pending} running, ${queue.size} queued ` +
56
+ `(CRAWLFORGE_MAX_ACTION_SESSIONS=${concurrency}). Retry shortly, or raise ` +
57
+ 'CRAWLFORGE_MAX_ACTION_SESSIONS / CRAWLFORGE_ACTION_QUEUE_TIMEOUT_MS if the machine has room.'
58
+ ));
59
+ }, timeoutMs);
60
+ timer.unref?.();
61
+
62
+ return queue.add(() => {
63
+ clearTimeout(timer);
64
+ return fn();
65
+ }, { signal: controller.signal });
66
+ }
67
+
68
+ return {
69
+ run,
70
+ concurrency,
71
+ timeoutMs,
72
+ get running() { return queue.pending; },
73
+ get queued() { return queue.size; }
74
+ };
75
+ }
76
+
77
+ /**
78
+ * The process-wide limiter both tools use. Tools take an `actionQueue` option
79
+ * so a test can hand them a small private one; production never passes it.
80
+ */
81
+ export const actionQueue = createActionQueue();
@@ -0,0 +1,182 @@
1
+ /**
2
+ * Consent — answer a cookie/consent wall with @duckduckgo/autoconsent before
3
+ * the chain's first action sees the page (browserOptions.consent).
4
+ *
5
+ * autoconsent ships a content script plus a rule set; the content script
6
+ * detects the CMP and clicks its reject (optOut) or accept (optIn) button, and
7
+ * reports each step as a message. It has to run in every frame: several CMPs
8
+ * (Sourcepoint on theguardian.com) render inside a cross-origin iframe.
9
+ *
10
+ * Messages come back through a queue in each frame that this module drains on
11
+ * a short poll, not through page.exposeBinding. A binding can be registered
12
+ * only once per page (a second navigate would throw), and on camoufox
13
+ * page.evaluate runs in an isolated world that a main-world binding is not
14
+ * visible from. Draining a queue works the same on both engines.
15
+ *
16
+ * Engines: on Chromium frame.evaluate runs in the page's main world, so the
17
+ * content script and its globals (window.autoconsentReceiveMessage,
18
+ * window.__crawlforgeConsent) are visible to page scripts. On camoufox it runs
19
+ * in an isolated world: DOM clicks work the same, but the few rules that read
20
+ * a CMP's JavaScript API (`eval` steps, e.g. Cookiebot's, OneTrust's self-test)
21
+ * cannot see page globals there and evaluate to false, so those rules fall
22
+ * back to their DOM steps or do not match.
23
+ *
24
+ * Nothing here throws and nothing fails a chain: no CMP is `action: 'none'`.
25
+ * The content script is not stopped at the cap — a popup it is still working
26
+ * on may be answered after this returns.
27
+ */
28
+
29
+ import { readFileSync } from 'node:fs';
30
+ import { createRequire } from 'node:module';
31
+ import { dirname, join } from 'node:path';
32
+
33
+ const POLL_MS = 100;
34
+ const AUTO_ACTION = { reject: 'optOut', accept: 'optIn' };
35
+ const QUEUE_KEY = '__crawlforgeConsent';
36
+ const KEPT_MESSAGES = ['cmpDetected', 'popupFound', 'optOutResult', 'optInResult', 'autoconsentDone'];
37
+
38
+ // Read on first use, not at import: ~860 KB that a chain without consent
39
+ // handling never needs.
40
+ let bundle = null;
41
+ function loadBundle() {
42
+ if (!bundle) {
43
+ const require = createRequire(import.meta.url);
44
+ const rulesPath = require.resolve('@duckduckgo/autoconsent/rules/rules.json');
45
+ // The package's exports map does not expose dist/, so it is reached from
46
+ // rules/ — the two ship side by side.
47
+ const script = readFileSync(join(dirname(rulesPath), '..', 'dist', 'autoconsent.playwright.js'), 'utf8');
48
+ const rules = JSON.parse(readFileSync(rulesPath, 'utf8')).autoconsent.map((rule) => ({
49
+ rule,
50
+ pattern: rule.runContext?.urlPattern ? new RegExp(rule.runContext.urlPattern) : null
51
+ }));
52
+ bundle = { script, rules };
53
+ }
54
+ return bundle;
55
+ }
56
+
57
+ // The rules that can apply to a frame (the content script checks again; this
58
+ // only keeps a 700 KB rule set out of every frame). Defaults per autoconsent:
59
+ // a rule runs in the main frame unless `main: false`, in a subframe only with
60
+ // `frame: true`.
61
+ function rulesFor(url, mainFrame) {
62
+ return loadBundle().rules
63
+ .filter(({ rule, pattern }) => {
64
+ const ctx = rule.runContext || {};
65
+ if (mainFrame ? ctx.main === false : ctx.frame !== true) return false;
66
+ return !pattern || pattern.test(url);
67
+ })
68
+ .map(({ rule }) => rule);
69
+ }
70
+
71
+ function injectScript(frame, autoAction, timeout, mainFrame) {
72
+ const config = {
73
+ enabled: true,
74
+ autoAction,
75
+ disabledCmps: [],
76
+ // Prehiding hides the popup before it is answered and unhides it if
77
+ // answering fails — a snapshot taken meanwhile would lie either way.
78
+ enablePrehide: false,
79
+ enableCosmeticRules: true,
80
+ // One detection attempt per 500 ms; stop looking at about the cap.
81
+ detectRetries: Math.max(1, Math.ceil(timeout / 500)),
82
+ // Run eval snippets inline in whatever world evaluate() gave us instead of
83
+ // round-tripping them through the message queue (see the header).
84
+ isMainWorld: true
85
+ };
86
+ const initResp = { type: 'initResp', config, rules: { autoconsent: rulesFor(frame.url(), mainFrame) } };
87
+ return `window.${QUEUE_KEY} = [];
88
+ window.autoconsentSendMessage = (m) => {
89
+ if (${JSON.stringify(KEPT_MESSAGES)}.includes(m.type)) window.${QUEUE_KEY}.push(m);
90
+ return Promise.resolve();
91
+ };
92
+ ${loadBundle().script}
93
+ ;window.autoconsentReceiveMessage(${JSON.stringify(initResp)});
94
+ true;`;
95
+ }
96
+
97
+ const DRAIN_SCRIPT = `(() => { const q = window.${QUEUE_KEY}; return q ? q.splice(0) : null; })()`;
98
+
99
+ // This frame's new messages; injects the content script into a document that
100
+ // has none yet (first poll, or the frame navigated since).
101
+ async function drainFrame(frame, autoAction, timeout, mainFrame) {
102
+ try {
103
+ const messages = await frame.evaluate(DRAIN_SCRIPT);
104
+ if (messages) return messages;
105
+ await frame.evaluate(injectScript(frame, autoAction, timeout, mainFrame));
106
+ } catch {
107
+ // Detached or navigating frame: the next poll sees its new document.
108
+ }
109
+ return [];
110
+ }
111
+
112
+ /**
113
+ * Answer the page's consent wall, if it has one autoconsent knows.
114
+ *
115
+ * @param {Page} page - Playwright page, already navigated
116
+ * @param {'off'|'reject'|'accept'} mode - browserOptions.consent
117
+ * @param {{timeout?: number}} [options] - cap in ms (default 2000)
118
+ * @returns {Promise<{cmp: string|null, action: string, ms: number}|null>}
119
+ * null for 'off'. `action` is 'optOut' / 'optIn' when the popup was
120
+ * answered, 'hide' when a cosmetic rule hid it, 'error' when the rule ran
121
+ * and failed, 'timeout' when a popup was found but not answered within the
122
+ * cap, and 'none' when no popup was found.
123
+ */
124
+ export async function handleConsent(page, mode, { timeout = 2000 } = {}) {
125
+ const autoAction = AUTO_ACTION[mode];
126
+ if (!autoAction) return null;
127
+
128
+ const start = Date.now();
129
+ const deadline = start + Math.max(0, timeout);
130
+ let cmp = null;
131
+ let action = 'none';
132
+
133
+ try {
134
+ const mainFrame = page.mainFrame();
135
+ // Frame -> true while a CMP it detected is still unanswered. One page can
136
+ // need two frames: on theguardian.com a top-frame rule hides Sourcepoint's
137
+ // container at once, and the rule in Sourcepoint's iframe then makes the
138
+ // actual choice — returning on the first `done` would report the hide.
139
+ const pending = new Map();
140
+ let finished = false;
141
+ while (!finished && Date.now() < deadline) {
142
+ const frames = page.frames().filter((f) => f === mainFrame || /^https?:/i.test(f.url()));
143
+ let timer;
144
+ const batches = await Promise.race([
145
+ Promise.all(frames.map((f) => drainFrame(f, autoAction, timeout, f === mainFrame))),
146
+ new Promise((resolve) => { timer = setTimeout(() => resolve([]), deadline - Date.now()); })
147
+ ]);
148
+ clearTimeout(timer);
149
+
150
+ batches.forEach((messages, i) => {
151
+ for (const m of messages) {
152
+ if (m.type === 'cmpDetected') {
153
+ if (!pending.has(frames[i])) pending.set(frames[i], true);
154
+ continue;
155
+ }
156
+ cmp = m.cmp;
157
+ if (m.type === 'popupFound') {
158
+ if (action === 'none') action = 'timeout';
159
+ } else if (m.type === 'autoconsentDone') {
160
+ action = m.isCosmetic ? 'hide' : autoAction;
161
+ pending.set(frames[i], false);
162
+ } else if (!m.result) {
163
+ // optOutResult/optInResult false: the rule ran and did not work.
164
+ action = 'error';
165
+ pending.set(frames[i], false);
166
+ }
167
+ }
168
+ });
169
+ // A CMP iframe removed mid-answer was closed by its own choice
170
+ // (Sourcepoint drops its iframe on one) — its done message goes with it.
171
+ for (const [frame, open] of pending) {
172
+ if (open && frame.isDetached()) pending.set(frame, false);
173
+ }
174
+ finished = action !== 'none' && action !== 'timeout' && ![...pending.values()].includes(true);
175
+ if (!finished) await new Promise((resolve) => setTimeout(resolve, Math.max(0, Math.min(POLL_MS, deadline - Date.now()))));
176
+ }
177
+ } catch {
178
+ action = 'error';
179
+ }
180
+
181
+ return { cmp, action, ms: Date.now() - start };
182
+ }