crawlforge-mcp-server 6.12.0 → 6.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -1
- package/server.js +14 -18
- package/src/cli/commands/actions.js +1 -0
- package/src/core/ActionExecutor.js +129 -63
- package/src/core/StealthBrowserManager.js +4 -2
- package/src/core/browser/actionQueue.js +81 -0
- package/src/core/browser/consent.js +182 -0
- package/src/core/browser/settle.js +141 -0
- package/src/core/browser/snapshot.js +241 -51
- package/src/server/screenshotResources.js +50 -0
- package/src/skills/agent-skills/crawlforge-batch-automation/references/actions.md +1 -1
- package/src/skills/agent-skills/crawlforge-stealth-browsing/SKILL.md +6 -0
- package/src/tools/advanced/BrowserSessionTool.js +18 -2
- package/src/tools/advanced/ScrapeWithActionsTool.js +84 -54
- package/src/tools/extract/extractEmbeddedState.js +11 -1
- package/src/utils/HumanBehaviorSimulator.js +35 -14
|
@@ -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. */
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* screenshotResources — move a scrape_with_actions result's screenshot bytes
|
|
3
|
+
* out of the inline result and into crawlforge://screenshot/{actionId}
|
|
4
|
+
* resources.
|
|
5
|
+
*
|
|
6
|
+
* The tool carries the same base64 in up to four places: `screenshots`,
|
|
7
|
+
* `actionResults[].result` (screenshot actions), `content.screenshots`
|
|
8
|
+
* (formats:["screenshots"] copies the same array) and `attempts[].results`
|
|
9
|
+
* (one per chain attempt). Stripping only the first left the others inline
|
|
10
|
+
* (Phase 0, 0.4). Each actionId is published once.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @param {object} result - the tool result, mutated in place
|
|
15
|
+
* @param {(actionId: string, data: string) => void} publish - stores the base64
|
|
16
|
+
* @returns {object} the same result
|
|
17
|
+
*/
|
|
18
|
+
export function stripScreenshotData(result, publish) {
|
|
19
|
+
const published = new Set();
|
|
20
|
+
|
|
21
|
+
const strip = (shot, actionId) => {
|
|
22
|
+
if (!actionId || typeof shot?.data !== 'string') return shot;
|
|
23
|
+
if (!published.has(actionId)) {
|
|
24
|
+
publish(actionId, shot.data);
|
|
25
|
+
published.add(actionId);
|
|
26
|
+
}
|
|
27
|
+
const { data, ...rest } = shot;
|
|
28
|
+
return { ...rest, resourceUri: `crawlforge://screenshot/${actionId}` };
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const stripResults = (results) => results.map((r) => (
|
|
32
|
+
r?.type === 'screenshot' ? { ...r, result: strip(r.result, r.id) } : r
|
|
33
|
+
));
|
|
34
|
+
|
|
35
|
+
if (Array.isArray(result.screenshots)) {
|
|
36
|
+
result.screenshots = result.screenshots.map((shot) => strip(shot, shot?.actionId));
|
|
37
|
+
}
|
|
38
|
+
if (Array.isArray(result.content?.screenshots)) {
|
|
39
|
+
result.content.screenshots = result.content.screenshots.map((shot) => strip(shot, shot?.actionId));
|
|
40
|
+
}
|
|
41
|
+
if (Array.isArray(result.actionResults)) {
|
|
42
|
+
result.actionResults = stripResults(result.actionResults);
|
|
43
|
+
}
|
|
44
|
+
if (Array.isArray(result.attempts)) {
|
|
45
|
+
result.attempts = result.attempts.map((a) => (
|
|
46
|
+
Array.isArray(a?.results) ? { ...a, results: stripResults(a.results) } : a
|
|
47
|
+
));
|
|
48
|
+
}
|
|
49
|
+
return result;
|
|
50
|
+
}
|
|
@@ -157,7 +157,7 @@ main frame only — elements inside iframes and shadow DOM get no refs.
|
|
|
157
157
|
| `formAutoFill` | — | Declarative form fill: `fields[]` + `submitSelector`. |
|
|
158
158
|
| `browserOptions` | — | `headless`, `userAgent`, `viewportWidth/Height`, `timeout`. |
|
|
159
159
|
| `continueOnActionError` | `false` | Keep going if one action fails. |
|
|
160
|
-
| `maxRetries` | `
|
|
160
|
+
| `maxRetries` | `0` | 0–3 whole-chain retries on failure. A retry re-navigates to the starting URL and replays every action; each run is reported under `attempts[]`. |
|
|
161
161
|
| `screenshotOnError` | `true` | Capture a screenshot when an error occurs. |
|
|
162
162
|
|
|
163
163
|
## CLI action-script format
|
|
@@ -91,6 +91,12 @@ Cloudflare scores the address and its ASN before it serves a challenge, so a
|
|
|
91
91
|
datacenter proxy changes the address without changing the class of address being
|
|
92
92
|
scored. Route through your own residential proxy — CrawlForge supplies none.
|
|
93
93
|
|
|
94
|
+
A self-hosted server can instead hand every stealth session to Browserbase:
|
|
95
|
+
set `CRAWLFORGE_BROWSER_BACKEND=browserbase` and `BROWSERBASE_API_KEY` (see
|
|
96
|
+
`docs/cloud-browser.md`). That account supplies the residential exit and any
|
|
97
|
+
CAPTCHA solving on its own terms; CrawlForge itself never solves a challenge —
|
|
98
|
+
a challenge page is reported as blocked.
|
|
99
|
+
|
|
94
100
|
Server-wide, set `CRAWLFORGE_STEALTH_PROXIES` (comma-separated proxy URLs): the
|
|
95
101
|
`scrape` escalation stage, `stealth_mode`, `browser_session`,
|
|
96
102
|
`scrape_with_actions`, the `deep_research` retry and the `agent` tool's automatic
|
|
@@ -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;
|