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
|
@@ -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
|
+
}
|
|
@@ -2,38 +2,47 @@
|
|
|
2
2
|
* Snapshot — the accessibility-style page tree that hands an agent stable
|
|
3
3
|
* element refs (`@e1`, `@e2`, …) to act on instead of guessed CSS selectors.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
* with no element refs at all, and `page._snapshotForAI()` is private API we
|
|
7
|
-
* will not depend on. The walk below is injected by us and returns the tree
|
|
8
|
-
* and the refs from one pass.
|
|
5
|
+
* TWO SOURCES, one public format:
|
|
9
6
|
*
|
|
10
|
-
*
|
|
7
|
+
* 1. `page.ariaSnapshot({ mode: 'ai' })` (Playwright ≥ 1.59; verified on
|
|
8
|
+
* 1.62.1, Chromium and Camoufox). It descends open shadow roots and
|
|
9
|
+
* iframes, and tags each visible node `[ref=e5]` (`[ref=f1e3]` in a
|
|
10
|
+
* frame) that the public `aria-ref=` selector engine resolves — shadow
|
|
11
|
+
* and frame refs included. Its YAML is translated into our tree below.
|
|
12
|
+
* 2. The injected walk (snapshotScript), kept as the fallback for a page
|
|
13
|
+
* where the native call throws. It stamps `data-cf-ref="e1"` on the
|
|
14
|
+
* elements it refs and sees neither shadow roots nor frames.
|
|
11
15
|
*
|
|
12
|
-
*
|
|
13
|
-
* refs, so a ref resolves to an ordinary CSS selector,
|
|
14
|
-
* `[data-cf-ref="e1"]`. That is what makes refs work with every existing
|
|
15
|
-
* action path for free, including the stealth human-behaviour code that
|
|
16
|
-
* takes a raw selector string.
|
|
17
|
-
* 2. In Node — a WeakMap<Page, state> cleared on every main-frame
|
|
18
|
-
* navigation. This is the half that DETECTS staleness. Without it a ref
|
|
19
|
-
* from a previous page would merely fail to match, and the caller would
|
|
20
|
-
* be told "selector not found" instead of "take a new snapshot".
|
|
16
|
+
* The result's `source` ('aria' | 'walk') says which one described the page.
|
|
21
17
|
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
18
|
+
* Refs are OURS, not Playwright's: `@e1…@eN`, contiguous, in document order
|
|
19
|
+
* across the page, its shadow roots and its frames. Playwright numbers every
|
|
20
|
+
* visible node (the body is usually its e1), so its ids have gaps and a page's
|
|
21
|
+
* one button would be e2; each of our refs instead maps to the selector that
|
|
22
|
+
* finds its element — `aria-ref=f1e3` natively, `[data-cf-ref="e1"]` from the
|
|
23
|
+
* walk. A resolved selector is therefore NOT always CSS: a consumer must hand
|
|
24
|
+
* it to page.locator()/waitForSelector(), never to document.querySelector().
|
|
25
|
+
*
|
|
26
|
+
* STALENESS lives in Node — a WeakMap<Page, state> cleared on every main-frame
|
|
27
|
+
* navigation. Without it a ref from a previous page would merely fail to
|
|
28
|
+
* match, and the caller would be told "selector not found" instead of "take a
|
|
29
|
+
* new snapshot". So a stale ref fails loudly, with the reason and the fix
|
|
30
|
+
* (StaleRefError), and never silently hits the wrong element.
|
|
24
31
|
*
|
|
25
32
|
* Not to be confused with src/core/SnapshotManager.js, which is change-
|
|
26
33
|
* tracking history.
|
|
27
34
|
*/
|
|
28
35
|
|
|
29
36
|
import { randomUUID } from 'node:crypto';
|
|
37
|
+
import { settlePage } from './settle.js';
|
|
30
38
|
|
|
31
39
|
export const REF_ATTRIBUTE = 'data-cf-ref';
|
|
32
40
|
export const DEFAULT_MAX_NODES = 200;
|
|
33
41
|
export const MAX_NODES_LIMIT = 1000;
|
|
34
42
|
|
|
35
|
-
//
|
|
36
|
-
// mid-
|
|
43
|
+
// Captures of one snapshot call, including the retry when the page navigates
|
|
44
|
+
// mid-capture (a fallback from the native snapshot to the walk is not one of
|
|
45
|
+
// them). Two: one retry is enough for a page that settles, and a page
|
|
37
46
|
// navigating repeatedly is not one a snapshot can describe.
|
|
38
47
|
const MAX_WALK_ATTEMPTS = 2;
|
|
39
48
|
|
|
@@ -44,6 +53,26 @@ const MAX_NAME_LENGTH = 120;
|
|
|
44
53
|
|
|
45
54
|
const REF_PATTERN = /^@e[1-9]\d*$/;
|
|
46
55
|
|
|
56
|
+
// Shared by both sources: the walk gets them as arguments, the translation of
|
|
57
|
+
// the native snapshot reads them directly.
|
|
58
|
+
const INTERACTIVE_ROLES = [
|
|
59
|
+
'button', 'link', 'checkbox', 'radio', 'textbox', 'combobox', 'menuitem',
|
|
60
|
+
'menuitemcheckbox', 'menuitemradio', 'tab', 'switch', 'option', 'searchbox',
|
|
61
|
+
'slider', 'spinbutton'
|
|
62
|
+
];
|
|
63
|
+
const STRUCTURAL_ROLES = [
|
|
64
|
+
'heading', 'banner', 'navigation', 'contentinfo', 'complementary', 'main',
|
|
65
|
+
'form', 'region', 'search', 'iframe'
|
|
66
|
+
];
|
|
67
|
+
const INTERACTIVE_ROLE_SET = new Set(INTERACTIVE_ROLES);
|
|
68
|
+
const STRUCTURAL_ROLE_SET = new Set(STRUCTURAL_ROLES);
|
|
69
|
+
|
|
70
|
+
// An editing host (`contenteditable`) has no ARIA role, so the native snapshot
|
|
71
|
+
// shows it as a plain `generic`. Only a page that has one pays for the per-node
|
|
72
|
+
// check that finds it, and each check gets this long before it counts as "no".
|
|
73
|
+
const EDITABLE_SELECTOR = '[contenteditable]:not([contenteditable="false"])';
|
|
74
|
+
const EDITABLE_CHECK_TIMEOUT_MS = 1000;
|
|
75
|
+
|
|
47
76
|
/** Thrown when a ref cannot be resolved against the page's current snapshot. */
|
|
48
77
|
export class StaleRefError extends Error {
|
|
49
78
|
constructor(message) {
|
|
@@ -66,17 +95,12 @@ export function isRef(selector) {
|
|
|
66
95
|
* JavaScript, so it has nothing to do with the ALLOW_JAVASCRIPT_EXECUTION flag
|
|
67
96
|
* that gates the `executeJavaScript` action. Do not put it behind that flag.
|
|
68
97
|
*/
|
|
69
|
-
function snapshotScript({
|
|
98
|
+
function snapshotScript({
|
|
99
|
+
refAttribute, interactiveOnly, maxNodes, maxIndent, maxNameLength, interactiveRoles, structuralRoles
|
|
100
|
+
}) {
|
|
70
101
|
const SKIP_TAGS = new Set(['script', 'style', 'noscript', 'template', 'head']);
|
|
71
|
-
const INTERACTIVE_ROLES = new Set(
|
|
72
|
-
|
|
73
|
-
'menuitemcheckbox', 'menuitemradio', 'tab', 'switch', 'option', 'searchbox',
|
|
74
|
-
'slider', 'spinbutton'
|
|
75
|
-
]);
|
|
76
|
-
const STRUCTURAL_ROLES = new Set([
|
|
77
|
-
'heading', 'banner', 'navigation', 'contentinfo', 'complementary', 'main',
|
|
78
|
-
'form', 'region', 'search'
|
|
79
|
-
]);
|
|
102
|
+
const INTERACTIVE_ROLES = new Set(interactiveRoles);
|
|
103
|
+
const STRUCTURAL_ROLES = new Set(structuralRoles);
|
|
80
104
|
const LANDMARK_TAGS = {
|
|
81
105
|
main: 'main', nav: 'navigation', header: 'banner', footer: 'contentinfo',
|
|
82
106
|
aside: 'complementary', form: 'form'
|
|
@@ -214,6 +238,148 @@ function snapshotScript({ refAttribute, interactiveOnly, maxNodes, maxIndent, ma
|
|
|
214
238
|
return { title: clean(document.title), lines, refs, truncated };
|
|
215
239
|
}
|
|
216
240
|
|
|
241
|
+
// Same name rules as the walk's clean()/truncate(): one line, no double quotes.
|
|
242
|
+
const cleanName = (value) => (value || '').replace(/\s+/g, ' ').trim().replace(/"/g, "'");
|
|
243
|
+
const truncateName = (value) =>
|
|
244
|
+
(value.length > MAX_NAME_LENGTH ? `${value.slice(0, MAX_NAME_LENGTH - 1)}…` : value);
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* One line of `page.ariaSnapshot({ mode: 'ai' })`:
|
|
248
|
+
*
|
|
249
|
+
* - role "name" [checked] [level=1] [ref=e5] [cursor=pointer]: text
|
|
250
|
+
*
|
|
251
|
+
* The name is JSON-quoted, or printed bare when it is itself `/…/`. A key YAML
|
|
252
|
+
* would misread (a name holding `: ` or ` #`) comes wrapped in single quotes
|
|
253
|
+
* with `''` for `'`; a text value that needs it is double-quoted. Property
|
|
254
|
+
* lines (`- /url: …`) and bare text (`- text: …`) are not nodes and return null.
|
|
255
|
+
*/
|
|
256
|
+
function parseAriaLine(line) {
|
|
257
|
+
const item = /^( *)- (.*)$/.exec(line);
|
|
258
|
+
if (!item) return null;
|
|
259
|
+
|
|
260
|
+
let key = item[2];
|
|
261
|
+
let rest = '';
|
|
262
|
+
const quoted = /^'((?:[^']|'')*)'(.*)$/.exec(key);
|
|
263
|
+
if (quoted) {
|
|
264
|
+
key = quoted[1].replace(/''/g, "'");
|
|
265
|
+
rest = quoted[2];
|
|
266
|
+
} else {
|
|
267
|
+
// An unquoted key never contains `:` followed by a space or the end.
|
|
268
|
+
const colon = key.search(/:(\s|$)/);
|
|
269
|
+
if (colon >= 0) [key, rest] = [key.slice(0, colon), key.slice(colon)];
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const parts = /^([a-z]+)(?: ("(?:[^"\\]|\\.)*"|\/.*\/(?= \[|$)))?(.*)$/.exec(key);
|
|
273
|
+
if (!parts || parts[1] === 'text') return null;
|
|
274
|
+
|
|
275
|
+
let text = rest.replace(/^:\s?/, '');
|
|
276
|
+
if (text.startsWith('"')) {
|
|
277
|
+
try {
|
|
278
|
+
text = JSON.parse(text.replace(/\\x([0-9a-f]{2})/gi, '\\u00$1'));
|
|
279
|
+
} catch {
|
|
280
|
+
// keep the raw value; it is only ever a fallback name
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const ref = /\[ref=([^\]\s]+)\]/.exec(parts[3]);
|
|
285
|
+
return {
|
|
286
|
+
indent: item[1].length,
|
|
287
|
+
role: parts[1],
|
|
288
|
+
name: parts[2] ? (parts[2].startsWith('"') ? JSON.parse(parts[2]) : parts[2]) : '',
|
|
289
|
+
text,
|
|
290
|
+
ref: ref ? ref[1] : null,
|
|
291
|
+
pointer: parts[3].includes('[cursor=pointer]')
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** Playwright refs of the `generic` nodes that are contenteditable editing hosts. */
|
|
296
|
+
async function editingHosts(page, nodes) {
|
|
297
|
+
const found = await Promise.all(page.frames().map((frame) =>
|
|
298
|
+
frame.locator(EDITABLE_SELECTOR).count().catch(() => 0)));
|
|
299
|
+
if (!found.some(Boolean)) return new Set();
|
|
300
|
+
|
|
301
|
+
const generics = nodes.filter((node) => node.role === 'generic');
|
|
302
|
+
const hosts = await Promise.all(generics.map((node) =>
|
|
303
|
+
page.locator(`aria-ref=${node.ref}`)
|
|
304
|
+
.evaluate(
|
|
305
|
+
(el) => el.isContentEditable && !(el.parentElement && el.parentElement.isContentEditable),
|
|
306
|
+
undefined,
|
|
307
|
+
{ timeout: EDITABLE_CHECK_TIMEOUT_MS }
|
|
308
|
+
)
|
|
309
|
+
.catch(() => false)));
|
|
310
|
+
return new Set(generics.filter((_, i) => hosts[i]).map((node) => node.ref));
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Describe the page with Playwright's native snapshot, in the walk's format.
|
|
315
|
+
*
|
|
316
|
+
* A node is interactive by role; or, having no such role, when it is an
|
|
317
|
+
* editing host (printed as the textbox it is typed into like) or the outermost
|
|
318
|
+
* node with a pointer cursor — the mark an onclick `<div>` usually carries.
|
|
319
|
+
* Nodes without a Playwright ref are left out: those are the invisible ones
|
|
320
|
+
* (display:none, zero-size, a closed <select>'s options). A `tabindex` or
|
|
321
|
+
* `onclick` element with neither a role nor a pointer cursor is not found —
|
|
322
|
+
* the YAML does not carry those attributes.
|
|
323
|
+
*/
|
|
324
|
+
async function ariaCapture(page, { interactiveOnly, maxNodes, timeout }) {
|
|
325
|
+
const yaml = await page.ariaSnapshot({ mode: 'ai', timeout });
|
|
326
|
+
const nodes = yaml.split('\n').map(parseAriaLine).filter((node) => node && node.ref);
|
|
327
|
+
const editable = await editingHosts(page, nodes);
|
|
328
|
+
|
|
329
|
+
const lines = [];
|
|
330
|
+
const refs = [];
|
|
331
|
+
let truncated = false;
|
|
332
|
+
// The emitted ancestors of the current node. Indentation follows EMITTED
|
|
333
|
+
// nesting, as in the walk, so it is this stack's depth, not the YAML's.
|
|
334
|
+
const emitted = [];
|
|
335
|
+
|
|
336
|
+
for (const node of nodes) {
|
|
337
|
+
while (emitted.length && emitted[emitted.length - 1].indent >= node.indent) emitted.pop();
|
|
338
|
+
|
|
339
|
+
const isHost = editable.has(node.ref);
|
|
340
|
+
const interactive = INTERACTIVE_ROLE_SET.has(node.role) || isHost ||
|
|
341
|
+
(node.pointer && !emitted.some((ancestor) => ancestor.interactive));
|
|
342
|
+
const structural = !interactive && !interactiveOnly && STRUCTURAL_ROLE_SET.has(node.role);
|
|
343
|
+
if (!interactive && !structural) continue;
|
|
344
|
+
|
|
345
|
+
if (lines.length >= maxNodes) {
|
|
346
|
+
truncated = true;
|
|
347
|
+
break;
|
|
348
|
+
}
|
|
349
|
+
const role = isHost ? 'textbox' : node.role;
|
|
350
|
+
// A control with no accessible name is captioned by its text, as the walk
|
|
351
|
+
// does; a landmark is named by its label only.
|
|
352
|
+
const name = truncateName(cleanName(node.name || (interactive ? node.text : '')));
|
|
353
|
+
let ref = '';
|
|
354
|
+
if (interactive) {
|
|
355
|
+
const id = `e${refs.length + 1}`;
|
|
356
|
+
refs.push({ id, role, name, selector: `aria-ref=${node.ref}` });
|
|
357
|
+
ref = `@${id} `;
|
|
358
|
+
}
|
|
359
|
+
lines.push(`${' '.repeat(Math.min(emitted.length + 1, MAX_INDENT))}${ref}[${role}]${name ? ` "${name}"` : ''}`);
|
|
360
|
+
emitted.push({ indent: node.indent, interactive });
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
return { title: cleanName(await page.title()), lines, refs, truncated };
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** The injected walk, with each ref's selector pointing at the stamp it left. */
|
|
367
|
+
async function walkCapture(page, { interactiveOnly, maxNodes }) {
|
|
368
|
+
const walk = await page.evaluate(snapshotScript, {
|
|
369
|
+
refAttribute: REF_ATTRIBUTE,
|
|
370
|
+
interactiveOnly,
|
|
371
|
+
maxNodes,
|
|
372
|
+
maxIndent: MAX_INDENT,
|
|
373
|
+
maxNameLength: MAX_NAME_LENGTH,
|
|
374
|
+
interactiveRoles: INTERACTIVE_ROLES,
|
|
375
|
+
structuralRoles: STRUCTURAL_ROLES
|
|
376
|
+
});
|
|
377
|
+
return {
|
|
378
|
+
...walk,
|
|
379
|
+
refs: walk.refs.map(({ id, role, name }) => ({ id, role, name, selector: `[${REF_ATTRIBUTE}="${id}"]` }))
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
|
|
217
383
|
function stateFor(page) {
|
|
218
384
|
let state = pageState.get(page);
|
|
219
385
|
if (!state) {
|
|
@@ -224,12 +390,15 @@ function stateFor(page) {
|
|
|
224
390
|
}
|
|
225
391
|
|
|
226
392
|
/**
|
|
227
|
-
*
|
|
393
|
+
* Describe the page and return its tree plus the refs it assigned. `source`
|
|
394
|
+
* in the result says whether the native snapshot or the walk produced it.
|
|
228
395
|
*
|
|
229
396
|
* @param {import('playwright').Page} page
|
|
230
397
|
* @param {object} [options]
|
|
231
398
|
* @param {boolean} [options.interactiveOnly=true] — false also emits headings and landmarks, unreffed
|
|
232
399
|
* @param {number} [options.maxNodes=200] — cap on emitted nodes, clamped to [1, MAX_NODES_LIMIT]
|
|
400
|
+
* @param {number} [options.timeout] — budget for the render wait before the capture (see settle.js),
|
|
401
|
+
* and for the native snapshot itself
|
|
233
402
|
*/
|
|
234
403
|
export async function captureSnapshot(page, options = {}) {
|
|
235
404
|
const interactiveOnly = options.interactiveOnly !== false;
|
|
@@ -242,27 +411,42 @@ export async function captureSnapshot(page, options = {}) {
|
|
|
242
411
|
// refs rather than leaving them to fail as a missing selector.
|
|
243
412
|
attachRefTracking(page);
|
|
244
413
|
|
|
414
|
+
// Walking before the page has rendered describes an empty shell. Both the
|
|
415
|
+
// snapshot action and browser_session's snapshot operation come through here.
|
|
416
|
+
const settle = await settlePage(page, { timeout: options.timeout });
|
|
417
|
+
|
|
245
418
|
const state = stateFor(page);
|
|
246
419
|
let title, lines, refs, truncated;
|
|
247
|
-
|
|
248
|
-
//
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
//
|
|
252
|
-
//
|
|
253
|
-
//
|
|
254
|
-
//
|
|
255
|
-
|
|
420
|
+
// The native snapshot unless this page object has none (a stub, an old
|
|
421
|
+
// Playwright) or it throws here; the walk is the fallback either way.
|
|
422
|
+
let source = typeof page.ariaSnapshot === 'function' ? 'aria' : 'walk';
|
|
423
|
+
|
|
424
|
+
// A navigation that commits WHILE the snapshot is running would otherwise
|
|
425
|
+
// leave us holding refs for a document that has gone, so `@e1` would match
|
|
426
|
+
// nothing and surface as a locator timeout instead of the named error D2
|
|
427
|
+
// requires. `generation` moves on every main-frame navigation, so a change
|
|
428
|
+
// across the capture means exactly that — whether the capture returned or
|
|
429
|
+
// threw because its context was destroyed. Capture the new document instead;
|
|
430
|
+
// if it navigates again, give up and leave the refs invalidated rather than
|
|
431
|
+
// publishing a tree for a page nobody is on.
|
|
432
|
+
for (let attempt = 0; ;) {
|
|
256
433
|
const generation = state.generation;
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
maxNodes,
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
434
|
+
const capture = source === 'aria' ? ariaCapture : walkCapture;
|
|
435
|
+
let result;
|
|
436
|
+
try {
|
|
437
|
+
result = await capture(page, { interactiveOnly, maxNodes, timeout: options.timeout });
|
|
438
|
+
} catch (error) {
|
|
439
|
+
if (state.generation === generation) {
|
|
440
|
+
if (source === 'walk') throw error;
|
|
441
|
+
source = 'walk'; // the native snapshot failed on a page that stayed put
|
|
442
|
+
continue;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
if (state.generation === generation) {
|
|
446
|
+
({ title, lines, refs, truncated } = result);
|
|
447
|
+
break;
|
|
448
|
+
}
|
|
449
|
+
if (++attempt >= MAX_WALK_ATTEMPTS) {
|
|
266
450
|
clearRefs(page);
|
|
267
451
|
throw new StaleRefError(
|
|
268
452
|
'The page navigated while the snapshot was being taken — take a new snapshot.'
|
|
@@ -272,7 +456,7 @@ export async function captureSnapshot(page, options = {}) {
|
|
|
272
456
|
|
|
273
457
|
const snapshotId = randomUUID().slice(0, 8);
|
|
274
458
|
state.snapshotId = snapshotId;
|
|
275
|
-
state.refs = new Map(refs.map(({ id, role, name,
|
|
459
|
+
state.refs = new Map(refs.map(({ id, role, name, selector }) => [id, { role, name, selector }]));
|
|
276
460
|
state.invalidated = false;
|
|
277
461
|
|
|
278
462
|
return {
|
|
@@ -283,12 +467,18 @@ export async function captureSnapshot(page, options = {}) {
|
|
|
283
467
|
refCount: refs.length,
|
|
284
468
|
nodeCount: lines.length,
|
|
285
469
|
truncated,
|
|
286
|
-
interactiveOnly
|
|
470
|
+
interactiveOnly,
|
|
471
|
+
source,
|
|
472
|
+
waited_ms: settle.waited_ms,
|
|
473
|
+
settled_by: settle.settled_by
|
|
287
474
|
};
|
|
288
475
|
}
|
|
289
476
|
|
|
290
477
|
/**
|
|
291
|
-
* Turn `@e1` into the
|
|
478
|
+
* Turn `@e1` into the selector for the element it named: `aria-ref=…` from the
|
|
479
|
+
* native snapshot, `[data-cf-ref="e1"]` from the walk. Either works anywhere a
|
|
480
|
+
* Playwright selector does (locator, waitForSelector, page.type) — not in a
|
|
481
|
+
* raw document.querySelector().
|
|
292
482
|
* Throws StaleRefError when the ref does not belong to the page's current
|
|
293
483
|
* snapshot — it never guesses.
|
|
294
484
|
*/
|
|
@@ -318,7 +508,7 @@ export function resolveRef(page, selector) {
|
|
|
318
508
|
: `Unknown element ref ${selector}: the current snapshot has ${count} refs (@e1-@e${count}) — take a new snapshot.`);
|
|
319
509
|
}
|
|
320
510
|
|
|
321
|
-
return
|
|
511
|
+
return state.refs.get(id).selector;
|
|
322
512
|
}
|
|
323
513
|
|
|
324
514
|
/** Register the navigation listener that invalidates this page's refs. Idempotent. */
|
|
@@ -42,6 +42,7 @@ import { isRemoteTransport } from '../../utils/remoteMode.js';
|
|
|
42
42
|
import { htmlToMarkdown } from '../../utils/htmlToMarkdown.js';
|
|
43
43
|
import { stealthDocumentVerdict } from '../../utils/stealthVerdict.js';
|
|
44
44
|
import { resolveStealthEngine } from '../../core/StealthBrowserManager.js';
|
|
45
|
+
import { actionQueue as sharedActionQueue } from '../../core/browser/actionQueue.js';
|
|
45
46
|
|
|
46
47
|
const SECOND = 1000;
|
|
47
48
|
|
|
@@ -207,7 +208,10 @@ export class BrowserSessionTool {
|
|
|
207
208
|
extractContentTool = null,
|
|
208
209
|
store = null,
|
|
209
210
|
storeOptions = {},
|
|
210
|
-
enableLogging = true
|
|
211
|
+
enableLogging = true,
|
|
212
|
+
// The process-wide browser-slot limiter shared with scrape_with_actions.
|
|
213
|
+
// Injectable for tests only.
|
|
214
|
+
actionQueue = sharedActionQueue
|
|
211
215
|
} = options;
|
|
212
216
|
|
|
213
217
|
// An injected executor belongs to whoever built it (server.js hands us
|
|
@@ -218,6 +222,7 @@ export class BrowserSessionTool {
|
|
|
218
222
|
this.extractContentTool = extractContentTool || new ExtractContentTool();
|
|
219
223
|
this.storeOptions = storeOptions;
|
|
220
224
|
this.store = store || new BrowserSessionStore(storeOptions);
|
|
225
|
+
this.actionQueue = actionQueue;
|
|
221
226
|
}
|
|
222
227
|
|
|
223
228
|
async execute(params) {
|
|
@@ -337,7 +342,18 @@ export class BrowserSessionTool {
|
|
|
337
342
|
// only then creates a page and navigates — closing the page itself if any
|
|
338
343
|
// of that fails. That is the whole of 2.5's gating on `open`, which is why
|
|
339
344
|
// this is not page.goto() behind a gate written here.
|
|
340
|
-
|
|
345
|
+
//
|
|
346
|
+
// Page creation and the first navigation take a slot from the browser
|
|
347
|
+
// budget scrape_with_actions draws on, so a burst of opens queues behind
|
|
348
|
+
// (and alongside) action chains instead of piling onto the box. The slot is
|
|
349
|
+
// released as soon as the page is up — a live session must NOT hold it for
|
|
350
|
+
// its TTL, or three open sessions would starve every scrape_with_actions
|
|
351
|
+
// call for up to an hour. How many sessions may stay open is the store's
|
|
352
|
+
// per-owner and total caps below, which still refuse rather than queue.
|
|
353
|
+
const page = await this.actionQueue.run(
|
|
354
|
+
() => this.actionExecutor.initializePage(params.url, browserOptions),
|
|
355
|
+
{ label: 'browser_session open' }
|
|
356
|
+
);
|
|
341
357
|
const releasePage = this.releaser(page, params.stealth);
|
|
342
358
|
|
|
343
359
|
let session;
|