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.
@@ -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
+ }