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 +2 -1
- package/server.js +6 -5
- package/src/core/ActionExecutor.js +54 -26
- package/src/core/StealthBrowserManager.js +4 -2
- package/src/core/browser/actionQueue.js +81 -0
- package/src/core/browser/consent.js +182 -0
- package/src/core/browser/settle.js +141 -0
- package/src/core/browser/snapshot.js +241 -51
- package/src/tools/advanced/BrowserSessionTool.js +18 -2
- package/src/tools/advanced/ScrapeWithActionsTool.js +74 -53
- package/src/utils/HumanBehaviorSimulator.js +35 -14
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "crawlforge-mcp-server",
|
|
3
|
-
"version": "6.
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
803
|
-
*
|
|
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 —
|
|
806
|
-
*
|
|
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}
|
|
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
|
-
*
|
|
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
|
|
869
|
-
* run against the outgoing page.
|
|
870
|
-
*
|
|
871
|
-
*
|
|
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 -
|
|
874
|
-
* @
|
|
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
|
-
|
|
878
|
-
|
|
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
|
|
1318
|
-
*
|
|
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
|
-
|
|
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
|
+
}
|