crawlforge-mcp-server 6.12.0 → 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 +14 -18
- package/src/cli/commands/actions.js +1 -0
- package/src/core/ActionExecutor.js +129 -63
- 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/server/screenshotResources.js +50 -0
- package/src/skills/agent-skills/crawlforge-batch-automation/references/actions.md +1 -1
- package/src/skills/agent-skills/crawlforge-stealth-browsing/SKILL.md +6 -0
- package/src/tools/advanced/BrowserSessionTool.js +18 -2
- package/src/tools/advanced/ScrapeWithActionsTool.js +84 -54
- package/src/tools/extract/extractEmbeddedState.js +11 -1
- package/src/utils/HumanBehaviorSimulator.js +35 -14
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Settle — wait for a page to finish rendering before a snapshot reads it, and
|
|
3
|
+
* after a click/press/select that may have changed it.
|
|
4
|
+
*
|
|
5
|
+
* `domcontentloaded` alone is not enough: a client-rendered page builds its DOM
|
|
6
|
+
* after it, and an SPA route change never fires it at all. A snapshot taken at
|
|
7
|
+
* that point describes an empty shell — the Amazon homepage came back as
|
|
8
|
+
* `[document]` with 0 nodes and a blank title. So the wait is two steps:
|
|
9
|
+
*
|
|
10
|
+
* 1. `load`, capped at LOAD_CAP_MS.
|
|
11
|
+
* 2. A quiet window: no DOM mutation for DOM_QUIET_MS AND no request in
|
|
12
|
+
* flight for NETWORK_QUIET_MS, capped at QUIET_CAP_MS. The network window
|
|
13
|
+
* counts from when the wait began, so a request an interaction is about to
|
|
14
|
+
* start is not beaten to the check by a page that was idle before it.
|
|
15
|
+
*
|
|
16
|
+
* Long-polling, websockets and analytics beacons can keep a request in flight
|
|
17
|
+
* forever, which is what the cap is for: `settled_by: 'cap'` is a normal
|
|
18
|
+
* outcome, not an error. Nothing here throws — a page that navigates mid-wait
|
|
19
|
+
* restarts the DOM window, and one that closes ends the wait.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export const LOAD_CAP_MS = 5000;
|
|
23
|
+
export const QUIET_CAP_MS = 3000;
|
|
24
|
+
export const DOM_QUIET_MS = 300;
|
|
25
|
+
export const NETWORK_QUIET_MS = 500;
|
|
26
|
+
const POLL_MS = 50;
|
|
27
|
+
|
|
28
|
+
// Where the in-page probe keeps itself. On the document, not the window, so a
|
|
29
|
+
// navigation (a new document) installs a fresh observer; non-enumerable, so it
|
|
30
|
+
// does not show up in a walk of the document's keys.
|
|
31
|
+
const PROBE_KEY = '__crawlforgeSettleProbe';
|
|
32
|
+
|
|
33
|
+
// Page -> { inFlight: Set<Request>, idleSince }. WeakMap so a closed page's
|
|
34
|
+
// tracker goes with it.
|
|
35
|
+
const trackers = new WeakMap();
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Count the page's in-flight requests. Idempotent: the listeners are attached
|
|
39
|
+
* once per page, however many times the page is settled.
|
|
40
|
+
*
|
|
41
|
+
* A Set rather than a counter because requests already in flight when the
|
|
42
|
+
* listeners attach finish without ever having been counted — deleting an
|
|
43
|
+
* unknown request is a no-op, where decrementing would drive a counter negative.
|
|
44
|
+
*/
|
|
45
|
+
export function trackRequests(page) {
|
|
46
|
+
let tracker = trackers.get(page);
|
|
47
|
+
if (tracker) return tracker;
|
|
48
|
+
|
|
49
|
+
tracker = { inFlight: new Set(), idleSince: Date.now() };
|
|
50
|
+
const done = (request) => {
|
|
51
|
+
if (tracker.inFlight.delete(request) && tracker.inFlight.size === 0) {
|
|
52
|
+
tracker.idleSince = Date.now();
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
page.on('request', (request) => tracker.inFlight.add(request));
|
|
56
|
+
page.on('requestfinished', done);
|
|
57
|
+
page.on('requestfailed', done);
|
|
58
|
+
trackers.set(page, tracker);
|
|
59
|
+
return tracker;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Injected: ms since the document last mutated. The first call on a document
|
|
64
|
+
* installs the observer and reports 0, so a fresh document is never quiet
|
|
65
|
+
* until DOM_QUIET_MS have actually been watched.
|
|
66
|
+
*/
|
|
67
|
+
function mutationProbe(key) {
|
|
68
|
+
let probe = document[key];
|
|
69
|
+
if (!probe) {
|
|
70
|
+
let last = performance.now();
|
|
71
|
+
new MutationObserver(() => { last = performance.now(); })
|
|
72
|
+
.observe(document, { subtree: true, childList: true, attributes: true, characterData: true });
|
|
73
|
+
probe = () => performance.now() - last;
|
|
74
|
+
Object.defineProperty(document, key, { value: probe });
|
|
75
|
+
}
|
|
76
|
+
return probe();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The probe's answer, or 0 (not quiet) if the page could not answer before the deadline. */
|
|
80
|
+
async function sinceLastMutation(page, deadline) {
|
|
81
|
+
// A navigation destroys the execution context mid-evaluate; that document is
|
|
82
|
+
// not quiet, and the next poll probes its successor.
|
|
83
|
+
const probe = page.evaluate(mutationProbe, PROBE_KEY).catch(() => 0);
|
|
84
|
+
let timer;
|
|
85
|
+
const expiry = new Promise((resolve) => {
|
|
86
|
+
timer = setTimeout(resolve, Math.max(0, deadline - Date.now()), 0);
|
|
87
|
+
});
|
|
88
|
+
try {
|
|
89
|
+
return await Promise.race([probe, expiry]);
|
|
90
|
+
} finally {
|
|
91
|
+
clearTimeout(timer);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Wait for `load`, then for a quiet window, never longer than `timeout` in all.
|
|
97
|
+
*
|
|
98
|
+
* @param {import('playwright').Page} page
|
|
99
|
+
* @param {object} [options]
|
|
100
|
+
* @param {number} [options.timeout] — overall budget in ms; default LOAD_CAP_MS + QUIET_CAP_MS
|
|
101
|
+
* @returns {Promise<{waited_ms: number, settled_by: 'quiet'|'cap'|'closed'}>}
|
|
102
|
+
* `quiet` — both windows held; `cap` — a cap or the budget ran out first;
|
|
103
|
+
* `closed` — the page closed during the wait.
|
|
104
|
+
*/
|
|
105
|
+
export async function settlePage(page, { timeout = LOAD_CAP_MS + QUIET_CAP_MS } = {}) {
|
|
106
|
+
const started = Date.now();
|
|
107
|
+
let settledBy = 'cap';
|
|
108
|
+
|
|
109
|
+
try {
|
|
110
|
+
// Attached before the load wait so the requests that hold `load` back are counted.
|
|
111
|
+
const requests = trackRequests(page);
|
|
112
|
+
|
|
113
|
+
try {
|
|
114
|
+
await page.waitForLoadState('load', { timeout: Math.max(1, Math.min(LOAD_CAP_MS, timeout)) });
|
|
115
|
+
} catch {
|
|
116
|
+
// Not loaded within the cap — the quiet window still gets its turn.
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const quietFrom = Date.now();
|
|
120
|
+
const deadline = Math.min(quietFrom + QUIET_CAP_MS, started + timeout);
|
|
121
|
+
while (Date.now() < deadline) {
|
|
122
|
+
if (page.isClosed()) {
|
|
123
|
+
settledBy = 'closed';
|
|
124
|
+
break;
|
|
125
|
+
}
|
|
126
|
+
const domQuietFor = await sinceLastMutation(page, deadline);
|
|
127
|
+
const networkQuietFor = requests.inFlight.size > 0
|
|
128
|
+
? 0
|
|
129
|
+
: Date.now() - Math.max(requests.idleSince, quietFrom);
|
|
130
|
+
if (domQuietFor >= DOM_QUIET_MS && networkQuietFor >= NETWORK_QUIET_MS) {
|
|
131
|
+
settledBy = 'quiet';
|
|
132
|
+
break;
|
|
133
|
+
}
|
|
134
|
+
await new Promise((resolve) => setTimeout(resolve, Math.min(POLL_MS, Math.max(0, deadline - Date.now()))));
|
|
135
|
+
}
|
|
136
|
+
} catch {
|
|
137
|
+
// A page torn down under us. Settling is never itself a failure.
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return { waited_ms: Date.now() - started, settled_by: settledBy };
|
|
141
|
+
}
|