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.
Files changed (42) hide show
  1. package/README.md +1 -1
  2. package/package.json +4 -3
  3. package/server.js +68 -22
  4. package/src/cli/commands/stealth.js +2 -2
  5. package/src/core/ActionExecutor.js +194 -64
  6. package/src/core/AuthManager.js +12 -0
  7. package/src/core/LLMsTxtAnalyzer.js +1 -0
  8. package/src/core/ResearchOrchestrator.js +10 -4
  9. package/src/core/StealthBrowserManager.js +29 -6
  10. package/src/core/browser/actionQueue.js +84 -0
  11. package/src/core/browser/consent.js +182 -0
  12. package/src/core/browser/settle.js +141 -0
  13. package/src/core/browser/snapshot.js +241 -51
  14. package/src/core/browser/windowState.js +88 -0
  15. package/src/core/crawlers/BFSCrawler.js +12 -3
  16. package/src/core/processing/BrowserProcessor.js +19 -7
  17. package/src/core/processing/PDFProcessor.js +15 -6
  18. package/src/server/fallbackHints.js +7 -4
  19. package/src/server/inlineThreshold.js +81 -19
  20. package/src/skills/agent-skills/crawlforge-getting-started/references/credits.md +1 -1
  21. package/src/skills/agent-skills/crawlforge-structured-extraction/SKILL.md +25 -6
  22. package/src/tools/advanced/BrowserSessionTool.js +56 -15
  23. package/src/tools/advanced/ScrapeWithActionsTool.js +74 -53
  24. package/src/tools/advanced/batchScrape/worker.js +3 -1
  25. package/src/tools/basic/_fetch.js +3 -1
  26. package/src/tools/crawl/_sessionContext.js +2 -1
  27. package/src/tools/crawl/mapSite.js +2 -1
  28. package/src/tools/extract/_fetchAndParse.js +1 -0
  29. package/src/tools/extract/extractContent.js +2 -1
  30. package/src/tools/extract/extractEmbeddedState.js +320 -71
  31. package/src/tools/extract/processDocument.js +5 -3
  32. package/src/tools/llmstxt/generateLLMsTxt.js +2 -1
  33. package/src/tools/templates/ScrapeTemplateTool.js +11 -3
  34. package/src/tools/tracking/trackChanges/differ.js +1 -0
  35. package/src/utils/HumanBehaviorSimulator.js +35 -14
  36. package/src/utils/complianceAudit.js +35 -5
  37. package/src/utils/impitRung.js +16 -5
  38. package/src/utils/resignedFetch.js +165 -0
  39. package/src/utils/robotsChecker.js +6 -1
  40. package/src/utils/robotsGate.js +124 -11
  41. package/src/utils/sitemapParser.js +2 -1
  42. 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
- async scrapeWithStealth({ url, engine, wait_for = 0, screenshot = false, stealthConfig = {} } = {}) {
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
- if (config.humanize) options.humanize = true;
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
+ }