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
|
@@ -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,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* windowState — read a page's framework state straight off `window`, after
|
|
3
|
+
* its JavaScript ran (extract_embedded_state's escalated path, plan Phase 3.2).
|
|
4
|
+
*
|
|
5
|
+
* The served HTML is not always where the state ends up: Nuxt 2 ships
|
|
6
|
+
* __NUXT__ as an IIFE the HTML parser cannot evaluate, and YouTube's
|
|
7
|
+
* ytInitialData is assigned by a script. Inside the stealth render those are
|
|
8
|
+
* plain globals, so they are read as the page itself sees them.
|
|
9
|
+
*
|
|
10
|
+
* Engines: on Chromium page.evaluate runs in the page's main world, where the
|
|
11
|
+
* globals live. On camoufox it runs in an isolated world that cannot see them
|
|
12
|
+
* (window.__NEXT_DATA__ is undefined there), but Firefox's Xray waiver,
|
|
13
|
+
* `window.wrappedJSObject`, reaches the page's own window from that world
|
|
14
|
+
* without evaluating anything in the page's — measured against camoufox
|
|
15
|
+
* 0.1.19, 2026-09-29. So the read works on both engines and the page never
|
|
16
|
+
* sees a script of ours run in its world.
|
|
17
|
+
*
|
|
18
|
+
* Each value is serialised INSIDE the page with its own try/catch: a cyclic or
|
|
19
|
+
* otherwise unserialisable global is skipped with a warning instead of
|
|
20
|
+
* failing the read of the others.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** The globals read, in this order. Only the ones present come back. */
|
|
24
|
+
export const WINDOW_STATE_GLOBALS = Object.freeze([
|
|
25
|
+
'__NEXT_DATA__',
|
|
26
|
+
'__NUXT__',
|
|
27
|
+
'__remixContext',
|
|
28
|
+
'ytInitialData',
|
|
29
|
+
'ytInitialPlayerResponse',
|
|
30
|
+
'__INITIAL_STATE__',
|
|
31
|
+
'__PRELOADED_STATE__',
|
|
32
|
+
'__APOLLO_STATE__',
|
|
33
|
+
'__TGT_DATA__',
|
|
34
|
+
'__PWS_DATA__'
|
|
35
|
+
]);
|
|
36
|
+
|
|
37
|
+
/** Said wherever these values are reported: they are not what the server sent. */
|
|
38
|
+
export const WINDOW_STATE_NOTE = 'read from window after JavaScript ran, not from the served HTML';
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Runs in the page. `{ json }` for a global that serialised, `{ error }` for
|
|
42
|
+
* one that did not; absent (null/undefined) globals are left out.
|
|
43
|
+
* @param {string[]} names
|
|
44
|
+
*/
|
|
45
|
+
export function serialiseWindowGlobals(names) {
|
|
46
|
+
const target = window.wrappedJSObject || window;
|
|
47
|
+
const out = {};
|
|
48
|
+
for (const name of names) {
|
|
49
|
+
try {
|
|
50
|
+
const value = target[name];
|
|
51
|
+
if (value === undefined || value === null) continue;
|
|
52
|
+
const json = JSON.stringify(value);
|
|
53
|
+
out[name] = typeof json === 'string' ? { json } : { error: 'it has no JSON form' };
|
|
54
|
+
} catch (error) {
|
|
55
|
+
out[name] = { error: String((error && error.message) || error) };
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* @param {{ evaluate: Function }} page a Playwright page
|
|
63
|
+
* @returns {Promise<{ state: Record<string, unknown>, warnings: string[] }>}
|
|
64
|
+
*/
|
|
65
|
+
export async function readWindowState(page) {
|
|
66
|
+
const warnings = [];
|
|
67
|
+
let raw;
|
|
68
|
+
try {
|
|
69
|
+
raw = await page.evaluate(serialiseWindowGlobals, [...WINDOW_STATE_GLOBALS]);
|
|
70
|
+
} catch (error) {
|
|
71
|
+
warnings.push(`window_state could not be read: ${String(error.message).split('\n')[0]}`);
|
|
72
|
+
return { state: {}, warnings };
|
|
73
|
+
}
|
|
74
|
+
const state = {};
|
|
75
|
+
for (const [name, entry] of Object.entries(raw || {})) {
|
|
76
|
+
if (entry && typeof entry.json === 'string') {
|
|
77
|
+
try {
|
|
78
|
+
state[name] = JSON.parse(entry.json);
|
|
79
|
+
continue;
|
|
80
|
+
} catch (error) {
|
|
81
|
+
warnings.push(`window_state: ${name} skipped - its JSON did not parse (${error.message})`);
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
warnings.push(`window_state: ${name} skipped - not serialisable (${entry?.error ?? 'unknown reason'})`);
|
|
86
|
+
}
|
|
87
|
+
return { state, warnings };
|
|
88
|
+
}
|
|
@@ -8,9 +8,10 @@ import { LinkAnalyzer } from '../analysis/LinkAnalyzer.js';
|
|
|
8
8
|
import { normalizeUrl, extractLinks, isValidUrl } from '../../utils/urlNormalizer.js';
|
|
9
9
|
import { Logger } from '../../utils/Logger.js';
|
|
10
10
|
import { safeFetch } from '../../utils/ssrfGuard.js';
|
|
11
|
-
import { robotsPreflight, RobotsDisallowedError } from '../../utils/robotsGate.js';
|
|
11
|
+
import { robotsPreflight, redirectGate, RobotsDisallowedError } from '../../utils/robotsGate.js';
|
|
12
12
|
import { throttleHost } from '../../utils/hostRateLimiter.js';
|
|
13
13
|
import { CRAWLFORGE_USER_AGENT, identityHeaders } from '../../utils/fetchIdentity.js';
|
|
14
|
+
import { signRequestHeaders } from '../../utils/webBotAuth.js';
|
|
14
15
|
import { pageTitle } from '../../utils/pageTitle.js';
|
|
15
16
|
import { extractMainContent, isThinMainContent } from '../../tools/scrape/_mainContent.js';
|
|
16
17
|
|
|
@@ -337,7 +338,10 @@ export class BFSCrawler {
|
|
|
337
338
|
'Accept-Language': 'en-US,en;q=0.5',
|
|
338
339
|
'Accept-Encoding': 'gzip, deflate',
|
|
339
340
|
'Connection': 'keep-alive',
|
|
340
|
-
'Upgrade-Insecure-Requests': '1'
|
|
341
|
+
'Upgrade-Insecure-Requests': '1',
|
|
342
|
+
// Web Bot Auth, as on every other fetching tool. crawl_deep pages went
|
|
343
|
+
// out with the identity alone: a claim with no proof.
|
|
344
|
+
...signRequestHeaders(url)
|
|
341
345
|
};
|
|
342
346
|
|
|
343
347
|
let headers = { ...defaultHeaders, ...domainRules.customHeaders };
|
|
@@ -357,7 +361,12 @@ export class BFSCrawler {
|
|
|
357
361
|
|
|
358
362
|
const response = await safeFetch(url, {
|
|
359
363
|
signal: controller.signal,
|
|
360
|
-
headers
|
|
364
|
+
headers,
|
|
365
|
+
onRedirect: redirectGate(url, {
|
|
366
|
+
respectRobots: this.respectRobots,
|
|
367
|
+
userAgent: this.userAgent,
|
|
368
|
+
tool: 'crawl_deep'
|
|
369
|
+
})
|
|
361
370
|
});
|
|
362
371
|
|
|
363
372
|
// Capture any cookies the server sets during the crawl
|
|
@@ -9,7 +9,7 @@ import { z } from 'zod';
|
|
|
9
9
|
import StealthBrowserManager from '../StealthBrowserManager.js';
|
|
10
10
|
import HumanBehaviorSimulator from '../../utils/HumanBehaviorSimulator.js';
|
|
11
11
|
import LocalizationManager from '../LocalizationManager.js';
|
|
12
|
-
import { safeGoto } from '../../utils/ssrfGuard.js';
|
|
12
|
+
import { safeGoto, assertNavigationAllowed } from '../../utils/ssrfGuard.js';
|
|
13
13
|
|
|
14
14
|
const BrowserProcessorSchema = z.object({
|
|
15
15
|
url: z.string().url(),
|
|
@@ -156,9 +156,12 @@ export class BrowserProcessor {
|
|
|
156
156
|
* @param {Object} params - Processing parameters
|
|
157
157
|
* @param {string} params.url - URL to process
|
|
158
158
|
* @param {Object} params.options - Browser options
|
|
159
|
+
* @param {{ onRedirect?: (to: string) => Promise<void> }} [gate] - what the
|
|
160
|
+
* calling tool's `preflightFetch` returned, so a redirect is gated the way
|
|
161
|
+
* the URL was. A second argument because the schema strips it from `params`.
|
|
159
162
|
* @returns {Promise<Object>} - Processing result with rendered content
|
|
160
163
|
*/
|
|
161
|
-
async processURL(params) {
|
|
164
|
+
async processURL(params, gate) {
|
|
162
165
|
const startTime = Date.now();
|
|
163
166
|
|
|
164
167
|
try {
|
|
@@ -178,7 +181,7 @@ export class BrowserProcessor {
|
|
|
178
181
|
|
|
179
182
|
try {
|
|
180
183
|
// Navigate and wait for content
|
|
181
|
-
const navigationResult = await this.navigateAndWait(page, url, processingOptions);
|
|
184
|
+
const navigationResult = await this.navigateAndWait(page, url, processingOptions, gate?.onRedirect);
|
|
182
185
|
|
|
183
186
|
// Extract content and metadata
|
|
184
187
|
const contentResult = await this.extractContent(page, processingOptions);
|
|
@@ -635,18 +638,23 @@ export class BrowserProcessor {
|
|
|
635
638
|
* @param {Page} page - Playwright page
|
|
636
639
|
* @param {string} url - URL to navigate to
|
|
637
640
|
* @param {Object} options - Navigation options
|
|
641
|
+
* @param {(to: string) => Promise<void>} [onRedirect] - the calling tool's
|
|
642
|
+
* redirect gate (robotsGate.js `redirectGate`)
|
|
638
643
|
* @returns {Promise<Object>} - Navigation result
|
|
639
644
|
*/
|
|
640
|
-
async navigateAndWait(page, url, options) {
|
|
645
|
+
async navigateAndWait(page, url, options, onRedirect) {
|
|
641
646
|
const startTime = Date.now();
|
|
642
647
|
|
|
643
648
|
// Navigate to URL. safeGoto adds the SSRF/DNS-rebinding guard on top of the
|
|
644
|
-
// robots/blocklist preflight the calling tool already ran, and re-checks
|
|
645
|
-
//
|
|
649
|
+
// robots/blocklist preflight the calling tool already ran, and re-checks
|
|
650
|
+
// where the navigation went, so a redirect can't carry us into a
|
|
651
|
+
// private/metadata range or past the gate the URL itself went through.
|
|
646
652
|
await safeGoto(page, url, {
|
|
647
653
|
waitUntil: 'domcontentloaded',
|
|
648
|
-
timeout: 30000
|
|
654
|
+
timeout: 30000,
|
|
655
|
+
onRedirect
|
|
649
656
|
});
|
|
657
|
+
const landedUrl = page.url();
|
|
650
658
|
|
|
651
659
|
// Wait for specific selector if provided
|
|
652
660
|
if (options.waitForSelector) {
|
|
@@ -687,6 +695,10 @@ export class BrowserProcessor {
|
|
|
687
695
|
}
|
|
688
696
|
}
|
|
689
697
|
|
|
698
|
+
// The waits above are time in which a client-side redirect can move the
|
|
699
|
+
// page on; the document about to be read is the one that has to pass.
|
|
700
|
+
await assertNavigationAllowed(page, landedUrl, null, onRedirect);
|
|
701
|
+
|
|
690
702
|
return {
|
|
691
703
|
navigationTime: Date.now() - startTime
|
|
692
704
|
};
|
|
@@ -10,6 +10,7 @@ import path from 'path';
|
|
|
10
10
|
import { safeFetch } from '../../utils/ssrfGuard.js';
|
|
11
11
|
import { config } from '../../constants/config.js';
|
|
12
12
|
import { identityHeaders } from '../../utils/fetchIdentity.js';
|
|
13
|
+
import { signRequestHeaders } from '../../utils/webBotAuth.js';
|
|
13
14
|
|
|
14
15
|
const PDFProcessorSchema = z.object({
|
|
15
16
|
// A Buffer is the 'buffer' source: process_document hands over a body it
|
|
@@ -85,9 +86,11 @@ export class PDFProcessor {
|
|
|
85
86
|
* @param {string} params.source - PDF source (URL, file path, or buffer)
|
|
86
87
|
* @param {string} params.sourceType - Type of source ('url', 'file', 'buffer')
|
|
87
88
|
* @param {Object} params.options - Processing options
|
|
89
|
+
* @param {{ headers: Record<string,string>, onRedirect: Function }} [gate] -
|
|
90
|
+
* what `preflightFetch` returned for a URL source
|
|
88
91
|
* @returns {Promise<Object>} - Processing result with text and metadata
|
|
89
92
|
*/
|
|
90
|
-
async processPDF(params) {
|
|
93
|
+
async processPDF(params, gate) {
|
|
91
94
|
const startTime = Date.now();
|
|
92
95
|
|
|
93
96
|
try {
|
|
@@ -106,7 +109,7 @@ export class PDFProcessor {
|
|
|
106
109
|
// Get PDF buffer based on source type
|
|
107
110
|
let pdfBuffer;
|
|
108
111
|
try {
|
|
109
|
-
pdfBuffer = await this.getPDFBuffer(source, sourceType);
|
|
112
|
+
pdfBuffer = await this.getPDFBuffer(source, sourceType, gate);
|
|
110
113
|
} catch (error) {
|
|
111
114
|
result.error = `Failed to load PDF: ${error.message}`;
|
|
112
115
|
result.processingTime = Date.now() - startTime;
|
|
@@ -214,12 +217,13 @@ export class PDFProcessor {
|
|
|
214
217
|
* Get PDF buffer from various sources
|
|
215
218
|
* @param {string} source - PDF source
|
|
216
219
|
* @param {string} sourceType - Source type
|
|
220
|
+
* @param {Object} [gate] - see processPDF
|
|
217
221
|
* @returns {Promise<Buffer>} - PDF buffer
|
|
218
222
|
*/
|
|
219
|
-
async getPDFBuffer(source, sourceType) {
|
|
223
|
+
async getPDFBuffer(source, sourceType, gate) {
|
|
220
224
|
switch (sourceType) {
|
|
221
225
|
case 'url':
|
|
222
|
-
return await this.downloadPDFFromURL(source);
|
|
226
|
+
return await this.downloadPDFFromURL(source, gate);
|
|
223
227
|
case 'file':
|
|
224
228
|
return await this.readPDFFromFile(source);
|
|
225
229
|
case 'buffer':
|
|
@@ -232,15 +236,20 @@ export class PDFProcessor {
|
|
|
232
236
|
/**
|
|
233
237
|
* Download PDF from URL
|
|
234
238
|
* @param {string} url - PDF URL
|
|
239
|
+
* @param {Object} [gate] - see processPDF
|
|
235
240
|
* @returns {Promise<Buffer>} - PDF buffer
|
|
236
241
|
*/
|
|
237
|
-
async downloadPDFFromURL(url) {
|
|
242
|
+
async downloadPDFFromURL(url, gate) {
|
|
238
243
|
try {
|
|
239
244
|
// `timeout` is not a fetch init option — undici/Node fetch silently
|
|
240
245
|
// ignores unknown properties, so only `signal` actually enforces a
|
|
241
246
|
// deadline here.
|
|
247
|
+
// The gate's headers are the identity the URL was cleared under plus the
|
|
248
|
+
// Web Bot Auth signature; this download used to discard them and go out
|
|
249
|
+
// unsigned. With no gate, sign as ourselves.
|
|
242
250
|
const response = await safeFetch(url, {
|
|
243
|
-
headers: identityHeaders(),
|
|
251
|
+
headers: gate?.headers ?? { ...identityHeaders(), ...signRequestHeaders(url) },
|
|
252
|
+
onRedirect: gate?.onRedirect,
|
|
244
253
|
signal: AbortSignal.timeout(30000)
|
|
245
254
|
});
|
|
246
255
|
|