crawlforge-mcp-server 6.12.1 → 6.16.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/README.md +1 -1
- package/package.json +4 -3
- package/server.js +68 -22
- package/src/cli/commands/stealth.js +2 -2
- package/src/core/ActionExecutor.js +194 -64
- package/src/core/AuthManager.js +12 -0
- package/src/core/LLMsTxtAnalyzer.js +1 -0
- package/src/core/ResearchOrchestrator.js +10 -4
- package/src/core/StealthBrowserManager.js +29 -6
- package/src/core/browser/actionQueue.js +84 -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/core/browser/windowState.js +88 -0
- package/src/core/crawlers/BFSCrawler.js +12 -3
- package/src/core/processing/BrowserProcessor.js +19 -7
- package/src/core/processing/PDFProcessor.js +15 -6
- package/src/server/fallbackHints.js +7 -4
- package/src/server/inlineThreshold.js +81 -19
- package/src/skills/agent-skills/crawlforge-getting-started/references/credits.md +1 -1
- package/src/skills/agent-skills/crawlforge-structured-extraction/SKILL.md +25 -6
- package/src/tools/advanced/BrowserSessionTool.js +56 -15
- package/src/tools/advanced/ScrapeWithActionsTool.js +74 -53
- package/src/tools/advanced/batchScrape/worker.js +3 -1
- package/src/tools/basic/_fetch.js +3 -1
- package/src/tools/crawl/_sessionContext.js +2 -1
- package/src/tools/crawl/mapSite.js +2 -1
- package/src/tools/extract/_fetchAndParse.js +1 -0
- package/src/tools/extract/extractContent.js +2 -1
- package/src/tools/extract/extractEmbeddedState.js +320 -71
- package/src/tools/extract/processDocument.js +5 -3
- package/src/tools/llmstxt/generateLLMsTxt.js +2 -1
- package/src/tools/templates/ScrapeTemplateTool.js +11 -3
- package/src/tools/tracking/trackChanges/differ.js +1 -0
- package/src/utils/HumanBehaviorSimulator.js +35 -14
- package/src/utils/complianceAudit.js +35 -5
- package/src/utils/impitRung.js +16 -5
- package/src/utils/resignedFetch.js +165 -0
- package/src/utils/robotsChecker.js +6 -1
- package/src/utils/robotsGate.js +124 -11
- package/src/utils/sitemapParser.js +2 -1
- package/src/utils/ssrfGuard.js +88 -11
|
@@ -17,11 +17,12 @@ import path from 'path';
|
|
|
17
17
|
import { createRequire } from 'module';
|
|
18
18
|
import HumanBehaviorSimulator from '../utils/HumanBehaviorSimulator.js';
|
|
19
19
|
import { BrowserContextPool } from './BrowserContextPool.js';
|
|
20
|
-
import { safeGoto } from '../utils/ssrfGuard.js';
|
|
20
|
+
import { safeGoto, assertNavigationAllowed } from '../utils/ssrfGuard.js';
|
|
21
21
|
import { looksLikeInterstitial, detectChallengePage } from '../utils/challengeDetection.js';
|
|
22
22
|
import { guardFirefoxPageErrors } from '../utils/firefoxPageErrorGuard.js';
|
|
23
23
|
import { serverStealthProxies } from '../constants/config.js';
|
|
24
24
|
import { ClearanceJar, sharedClearanceJar } from './ClearanceJar.js';
|
|
25
|
+
import { readWindowState } from './browser/windowState.js';
|
|
25
26
|
|
|
26
27
|
// Grace given to a document that rendered no title and no text (see _waitOutEmptyDocument).
|
|
27
28
|
export const EMPTY_DOCUMENT_GRACE_MS = 8000;
|
|
@@ -2844,7 +2845,16 @@ export class StealthBrowserManager {
|
|
|
2844
2845
|
return this._clickTurnstile(page);
|
|
2845
2846
|
}
|
|
2846
2847
|
|
|
2847
|
-
|
|
2848
|
+
/**
|
|
2849
|
+
* @param {object} options
|
|
2850
|
+
* @param {boolean} [options.readWindowState] also read the framework globals
|
|
2851
|
+
* off `window` once the page has settled (extract_embedded_state's
|
|
2852
|
+
* escalation, src/core/browser/windowState.js); returned as `windowState`
|
|
2853
|
+
* @param {(to: string) => Promise<void>} [options.onRedirect] the caller's
|
|
2854
|
+
* gate for wherever the page is redirected to (robotsGate.js
|
|
2855
|
+
* `redirectGate`); a refusal throws and nothing of the page is returned
|
|
2856
|
+
*/
|
|
2857
|
+
async scrapeWithStealth({ url, engine, wait_for = 0, screenshot = false, stealthConfig = {}, readWindowState: wantWindowState = false, onRedirect } = {}) {
|
|
2848
2858
|
if (!url) throw new Error('scrapeWithStealth requires a url');
|
|
2849
2859
|
|
|
2850
2860
|
const { contextId, engineFallbackWarning = null } = await this.createStealthContext({ ...stealthConfig, engine });
|
|
@@ -2870,7 +2880,8 @@ export class StealthBrowserManager {
|
|
|
2870
2880
|
});
|
|
2871
2881
|
// SSRF guard at the navigation boundary: the stealth engine resolves DNS
|
|
2872
2882
|
// itself, so a URL/host-only check would miss rebinding to private/metadata IPs.
|
|
2873
|
-
const response = await safeGoto(page, url, { waitUntil: 'domcontentloaded' });
|
|
2883
|
+
const response = await safeGoto(page, url, { waitUntil: 'domcontentloaded', onRedirect });
|
|
2884
|
+
const landedUrl = page.url();
|
|
2874
2885
|
if (response && status === null) status = response.status();
|
|
2875
2886
|
if (wait_for > 0) await page.waitForTimeout(wait_for);
|
|
2876
2887
|
// Cloudflare's and Vercel's JavaScript challenges solve themselves in a
|
|
@@ -2888,6 +2899,10 @@ export class StealthBrowserManager {
|
|
|
2888
2899
|
// a successful scrape.
|
|
2889
2900
|
const renderMs = await this._settleRender(page);
|
|
2890
2901
|
const gracedMs = emptyGraceMs + renderMs;
|
|
2902
|
+
// The waits above are time in which a challenge or a client-side redirect
|
|
2903
|
+
// can move the page on. The document read below is the one that has to
|
|
2904
|
+
// pass, so wherever the page stands now is checked like a redirect hop.
|
|
2905
|
+
await assertNavigationAllowed(page, landedUrl, null, onRedirect);
|
|
2891
2906
|
|
|
2892
2907
|
// A failure to read the document is a failure. With every read wrapped
|
|
2893
2908
|
// in .catch(() => ''), a renderer that crashed or a page closed during
|
|
@@ -2913,6 +2928,12 @@ export class StealthBrowserManager {
|
|
|
2913
2928
|
const shot = screenshot
|
|
2914
2929
|
? await page.screenshot({ encoding: 'base64', fullPage: false }).catch(() => null)
|
|
2915
2930
|
: null;
|
|
2931
|
+
let windowState;
|
|
2932
|
+
if (wantWindowState) {
|
|
2933
|
+
const read = await readWindowState(page);
|
|
2934
|
+
windowState = read.state;
|
|
2935
|
+
warnings.push(...read.warnings);
|
|
2936
|
+
}
|
|
2916
2937
|
|
|
2917
2938
|
// Still a wall after everything above: whatever clearance this context
|
|
2918
2939
|
// carried for the site did not work, so closeContext drops it instead of
|
|
@@ -2921,7 +2942,7 @@ export class StealthBrowserManager {
|
|
|
2921
2942
|
this._markBlocked(contextId, [url, page.url()]);
|
|
2922
2943
|
}
|
|
2923
2944
|
|
|
2924
|
-
return { success: true, url, title, text, html, screenshot: shot, status, gracedMs, engine: engineUsed, warnings };
|
|
2945
|
+
return { success: true, url, title, text, html, screenshot: shot, status, gracedMs, engine: engineUsed, warnings, ...(windowState ? { windowState } : {}) };
|
|
2925
2946
|
} finally {
|
|
2926
2947
|
await this.closeContext(contextId).catch(() => {});
|
|
2927
2948
|
}
|
|
@@ -3638,8 +3659,10 @@ export class CamoufoxAdapter extends BrowserEngine {
|
|
|
3638
3659
|
// `blockWebRTC: false` is the stealthier setting behind a proxy.
|
|
3639
3660
|
if (config.blockWebRTC) options.block_webrtc = true;
|
|
3640
3661
|
// Native cursor humanization: camoufox moves the pointer along a plausible
|
|
3641
|
-
// path rather than teleporting it.
|
|
3642
|
-
|
|
3662
|
+
// path rather than teleporting it. The number caps one move in seconds;
|
|
3663
|
+
// `true` means camoufox's 1.5 s default, which made a single stealth click
|
|
3664
|
+
// cost 2 s or more of cursor travel alone.
|
|
3665
|
+
if (config.humanize) options.humanize = 0.5;
|
|
3643
3666
|
// Pin the persona to the installed binary's Firefox major so the UA's two
|
|
3644
3667
|
// version tokens agree. Left unpinned, camoufox's own version rewrite
|
|
3645
3668
|
// reaches only `rv:` and about half of all launches announce a Firefox
|
|
@@ -0,0 +1,84 @@
|
|
|
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
|
+
// Not unref()'d: a caller is awaiting this wait, so it must keep the event
|
|
61
|
+
// loop alive. Unref'd, Node 22 could exit mid-wait with the promise still
|
|
62
|
+
// pending (it did, under node:test in CI). The timer never outlives the
|
|
63
|
+
// wait — it is cleared when the job starts.
|
|
64
|
+
|
|
65
|
+
return queue.add(() => {
|
|
66
|
+
clearTimeout(timer);
|
|
67
|
+
return fn();
|
|
68
|
+
}, { signal: controller.signal });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
run,
|
|
73
|
+
concurrency,
|
|
74
|
+
timeoutMs,
|
|
75
|
+
get running() { return queue.pending; },
|
|
76
|
+
get queued() { return queue.size; }
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The process-wide limiter both tools use. Tools take an `actionQueue` option
|
|
82
|
+
* so a test can hand them a small private one; production never passes it.
|
|
83
|
+
*/
|
|
84
|
+
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
|
+
}
|
|
@@ -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
|
+
}
|