crawlforge-mcp-server 6.14.0 → 6.17.1
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 +3 -3
- package/server.js +74 -22
- package/src/cli/commands/stealth.js +2 -2
- package/src/core/ActionExecutor.js +219 -39
- 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 +117 -13
- package/src/core/browser/actionQueue.js +4 -1
- package/src/core/browser/windowState.js +88 -0
- package/src/core/crawlers/BFSCrawler.js +12 -3
- package/src/core/processing/BrowserProcessor.js +27 -8
- 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 +38 -13
- package/src/tools/advanced/ScrapeWithActionsTool.js +40 -4
- 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/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/secretMask.js +1 -1
- package/src/utils/sitemapParser.js +2 -1
- package/src/utils/ssrfGuard.js +88 -11
|
@@ -20,9 +20,9 @@ export const MAX_INLINE_CHARS_PARAM = {
|
|
|
20
20
|
/**
|
|
21
21
|
* Per-tool rule. `textPaths` are dotted paths tried in order; the first one
|
|
22
22
|
* holding a string becomes the text view the preview and read_result work
|
|
23
|
-
* on. An empty list means the pretty-printed JSON is the view. `
|
|
24
|
-
*
|
|
25
|
-
*
|
|
23
|
+
* on. An empty list means the pretty-printed JSON is the view. `when` gates
|
|
24
|
+
* on params. extract_embedded_state has a shape of its own (shapeEmbeddedState
|
|
25
|
+
* below).
|
|
26
26
|
*/
|
|
27
27
|
/** The browser_session operations that hand back content worth shaping. */
|
|
28
28
|
const BROWSER_SESSION_CONTENT_OPERATIONS = new Set(['snapshot', 'act', 'read']);
|
|
@@ -52,7 +52,10 @@ export const INLINE_THRESHOLD_TOOLS = Object.freeze({
|
|
|
52
52
|
},
|
|
53
53
|
process_document: { textPaths: ['content.text'], truncate: true },
|
|
54
54
|
deep_research: { textPaths: [], truncate: true },
|
|
55
|
-
|
|
55
|
+
// Truncated since plan Phase 3.1: returned whole, producthunt.com and
|
|
56
|
+
// zappos.com overflowed the client. Shaped by shapeEmbeddedState, which never
|
|
57
|
+
// cuts inside a JSON value.
|
|
58
|
+
extract_embedded_state: { textPaths: [], truncate: true }
|
|
56
59
|
});
|
|
57
60
|
|
|
58
61
|
/** param -> env (an int of at least 1,000) -> default. */
|
|
@@ -147,21 +150,8 @@ export function applyInlineThreshold(toolName, resultObject, params, { store, en
|
|
|
147
150
|
const viewDesc = view === 'text' ? `the ${view_path} text` : 'the pretty-printed JSON';
|
|
148
151
|
const readWith = 'read it with read_result (1 credit) - operation "search" (query), "slice" (offset, length), "lines" or "json_path" (path) - and do not fetch the page again';
|
|
149
152
|
|
|
150
|
-
if (
|
|
151
|
-
|
|
152
|
-
return {
|
|
153
|
-
result: {
|
|
154
|
-
...resultObject,
|
|
155
|
-
result_handle: handle,
|
|
156
|
-
total_chars: text.length,
|
|
157
|
-
view,
|
|
158
|
-
view_path,
|
|
159
|
-
truncated: false,
|
|
160
|
-
expires_at,
|
|
161
|
-
warnings: [...warningsOf(resultObject), hint]
|
|
162
|
-
},
|
|
163
|
-
stored: true
|
|
164
|
-
};
|
|
153
|
+
if (toolName === 'extract_embedded_state') {
|
|
154
|
+
return { result: shapeEmbeddedState(resultObject, { json, text, maxInline, handle, expires_at, readWith }), stored: true };
|
|
165
155
|
}
|
|
166
156
|
|
|
167
157
|
const preview = text.slice(0, maxInline);
|
|
@@ -200,3 +190,75 @@ export function applyInlineThreshold(toolName, resultObject, params, { store, en
|
|
|
200
190
|
});
|
|
201
191
|
return { result: shaped, stored: true };
|
|
202
192
|
}
|
|
193
|
+
|
|
194
|
+
/** Object.keys(data) for a plain object, "array(<n>)" for an array, null otherwise. */
|
|
195
|
+
export function embeddedDataKeys(data) {
|
|
196
|
+
if (Array.isArray(data)) return `array(${data.length})`;
|
|
197
|
+
if (data !== null && typeof data === 'object') return Object.keys(data);
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* The longest run of whole lines from the start of `text` whose JSON-string
|
|
203
|
+
* form fits in `budget` characters. A line that does not fit ends the preview,
|
|
204
|
+
* so it never stops inside a value; with a budget too small for the first
|
|
205
|
+
* line it is empty.
|
|
206
|
+
*/
|
|
207
|
+
export function wholeLinePreview(text, budget) {
|
|
208
|
+
const room = budget - 2; // the string's own quotes
|
|
209
|
+
let used = 0;
|
|
210
|
+
let end = 0;
|
|
211
|
+
let start = 0;
|
|
212
|
+
while (start < text.length) {
|
|
213
|
+
const newline = text.indexOf('\n', start);
|
|
214
|
+
const stop = newline === -1 ? text.length : newline;
|
|
215
|
+
// A line's escaped length, plus the escaped newline that joins it to the last.
|
|
216
|
+
const cost = JSON.stringify(text.slice(start, stop)).length - 2 + (end > 0 ? 2 : 0);
|
|
217
|
+
if (used + cost > room) break;
|
|
218
|
+
used += cost;
|
|
219
|
+
end = stop;
|
|
220
|
+
start = stop + 1;
|
|
221
|
+
}
|
|
222
|
+
return text.slice(0, end);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* extract_embedded_state over the limit (plan Phase 3.1). What stays inline is
|
|
227
|
+
* everything that says what the page carries — url, path, bytes, the whole
|
|
228
|
+
* `found` list, data_keys, the escalation report and window_state's list —
|
|
229
|
+
* then a preview of the pretty-printed JSON cut at a line boundary. The
|
|
230
|
+
* preview gets whatever max_inline_chars leaves after the rest, measured as
|
|
231
|
+
* compact JSON, so the whole inline result stays within the limit whenever
|
|
232
|
+
* the rest fits. The full result is under the handle for read_result
|
|
233
|
+
* json_path. The REST route shapes it the same way; keep the two in step.
|
|
234
|
+
*/
|
|
235
|
+
function shapeEmbeddedState(resultObject, { json, text, maxInline, handle, expires_at, readWith }) {
|
|
236
|
+
const dataKeys = embeddedDataKeys(resultObject.data);
|
|
237
|
+
const firstKey = Array.isArray(dataKeys) && dataKeys.length > 0 ? dataKeys[0] : null;
|
|
238
|
+
const example = firstKey && /^[A-Za-z_$][\w$]*$/.test(firstKey) ? ` Read one payload with operation "json_path", path "data.${firstKey}".` : '';
|
|
239
|
+
const hint = `Result is ${json.length} chars as JSON, over the inline limit of ${maxInline}; preview holds the pretty-printed JSON up to the last whole line that fits (${text.length} chars in total), data_keys lists the top-level keys of data, and the full result is kept for 1 hour under result_handle ${handle}: ${readWith}.${example} Or re-run with path, or keys_only:true, to ask for less.`;
|
|
240
|
+
|
|
241
|
+
const shaped = {};
|
|
242
|
+
for (const key of ['url', 'path', 'bytes', 'found']) {
|
|
243
|
+
if (key in resultObject) shaped[key] = resultObject[key];
|
|
244
|
+
}
|
|
245
|
+
shaped.data_keys = dataKeys;
|
|
246
|
+
if ('escalated' in resultObject) shaped.escalated = resultObject.escalated;
|
|
247
|
+
if (resultObject.stealth) shaped.stealth = resultObject.stealth;
|
|
248
|
+
if (resultObject.window_state && typeof resultObject.window_state === 'object') {
|
|
249
|
+
shaped.window_state = { note: resultObject.window_state.note, found: resultObject.window_state.found };
|
|
250
|
+
}
|
|
251
|
+
const tail = {
|
|
252
|
+
result_handle: handle,
|
|
253
|
+
total_chars: text.length,
|
|
254
|
+
view: 'json',
|
|
255
|
+
view_path: null,
|
|
256
|
+
truncated: true,
|
|
257
|
+
expires_at,
|
|
258
|
+
warnings: [...warningsOf(resultObject), hint]
|
|
259
|
+
};
|
|
260
|
+
// `"preview":"",` is what the key itself adds once the value is in.
|
|
261
|
+
const envelope = JSON.stringify({ ...shaped, preview: '', ...tail }).length;
|
|
262
|
+
const preview = wholeLinePreview(text, Math.max(0, maxInline - envelope + 2));
|
|
263
|
+
return { ...shaped, preview, ...tail };
|
|
264
|
+
}
|
|
@@ -22,7 +22,7 @@ metered; there is no free tier. Tools marked "scales" cost more as work grows.
|
|
|
22
22
|
|------|-------|
|
|
23
23
|
| `scrape` | Unified multi-format single fetch. A `highlights` or `question` format adds 1 once per call; `mode:"model"` on one of them adds 3 once. `escalate:true` adds 5 as the projected ceiling; the actual charge drops back to the base when the plain fetch succeeded and no escalation ran. |
|
|
24
24
|
| `scrape_structured` | CSS-selector extraction. |
|
|
25
|
-
| `extract_embedded_state` | Embedded JS state (`__NEXT_DATA__`, RSC, Nuxt, Apollo, Redux). |
|
|
25
|
+
| `extract_embedded_state` | Embedded JS state (`__NEXT_DATA__`, RSC, Nuxt, Apollo, Redux). `escalate:true` adds 5 as the projected ceiling for the stealth re-read; the actual charge drops back to 2 when the plain fetch succeeded and no escalation ran. `keys_only` and `find` cost nothing extra. |
|
|
26
26
|
| `extract_content` | Readability-cleaned article. |
|
|
27
27
|
| `map_site` | URL discovery / sitemap. |
|
|
28
28
|
| `process_document` | PDF / DOCX / TXT parsing. |
|
|
@@ -36,15 +36,34 @@ already, exact and typed — that beats asking a model to read them off the rend
|
|
|
36
36
|
{ "tool": "extract_embedded_state", "params": { "url": "https://www.ticketmaster.com/discover/concerts", "path": "next_data.props.pageProps" } }
|
|
37
37
|
```
|
|
38
38
|
|
|
39
|
-
Finds `__NEXT_DATA__`, `self.__next_f` (React Server Component payloads
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
39
|
+
Finds `__NEXT_DATA__`, `self.__next_f` (React Server Component payloads, with
|
|
40
|
+
`$<id>` references between rows resolved and a `data_rows` index of the rows
|
|
41
|
+
carrying data), `window.__NUXT__` and Nuxt 3's `__NUXT_DATA__` (decoded as
|
|
42
|
+
`nuxt_data`; `raw: true` also keeps the undecoded array), SvelteKit `kit.start`
|
|
43
|
+
data, `__APOLLO_STATE__`, `__INITIAL_STATE__`, `__PRELOADED_STATE__`,
|
|
44
|
+
`ytInitialData`, `ytInitialPlayerResponse`, Inertia `data-page`, Shopify meta and
|
|
45
|
+
product JSON, JSON `data-*` attributes and `<script type="application/json">`
|
|
46
|
+
blocks, keyed by source name. No LLM in the path, so values are exact rather
|
|
47
|
+
than inferred.
|
|
43
48
|
|
|
44
49
|
These payloads are routinely over a megabyte — pass `path` (dotted keys and
|
|
45
50
|
array indexes, e.g. `next_data.props.pageProps` or `next_f[0].f`) to return one
|
|
46
|
-
subtree
|
|
47
|
-
|
|
51
|
+
subtree, or `keys_only: true` first to see the first two levels of keys (types,
|
|
52
|
+
no values) before choosing one. When you know a field's name but not where it
|
|
53
|
+
is, `find: "<key>"` returns every path holding it (case-insensitive, up to 50,
|
|
54
|
+
each with a 200-character preview) instead of the data; each path can be passed
|
|
55
|
+
straight back as `path`. `find` and `keys_only` cannot be combined. A result
|
|
56
|
+
over `max_inline_chars` comes back as a `preview` (whole lines of the JSON),
|
|
57
|
+
`data_keys` and a `result_handle`: read the
|
|
58
|
+
rest with `read_result` operation `json_path` (e.g. path `data.next_data.props`)
|
|
59
|
+
instead of fetching again.
|
|
60
|
+
|
|
61
|
+
On a site that blocks a plain fetch (403/429/challenge page), pass
|
|
62
|
+
`escalate: true` (projected 7, charged 2 when the plain fetch works): the stealth
|
|
63
|
+
browser re-reads the page, the same parser runs on it, and the framework globals
|
|
64
|
+
are read off `window` after JavaScript ran — `ytInitialData`, `__NUXT__`,
|
|
65
|
+
`__remixContext` and others, under `window_state` (path
|
|
66
|
+
`window_state.ytInitialData.contents`). A 404 or 5xx never escalates.
|
|
48
67
|
|
|
49
68
|
## scrape_template — known sites, zero selectors (cost: 1)
|
|
50
69
|
|
|
@@ -111,8 +111,10 @@ const BrowserSessionSchema = z.object({
|
|
|
111
111
|
timeout: z.number().min(10000).max(120000).default(30000),
|
|
112
112
|
|
|
113
113
|
// Applies to the call it is sent on: on `open` to the first load, on `act` to
|
|
114
|
-
// every navigate in that call
|
|
115
|
-
//
|
|
114
|
+
// every navigate in that call, and on any call to wherever the page has moved
|
|
115
|
+
// since it was last checked (a click, a form, a script). It is never
|
|
116
|
+
// remembered by the session, so an override has to be repeated as
|
|
117
|
+
// deliberately as it was made. A URL that passed is not asked about again.
|
|
116
118
|
respect_robots: z.boolean().optional(),
|
|
117
119
|
|
|
118
120
|
// snapshot
|
|
@@ -358,6 +360,8 @@ export class BrowserSessionTool {
|
|
|
358
360
|
|
|
359
361
|
let session;
|
|
360
362
|
try {
|
|
363
|
+
// The load was gated; a script may have moved the page on since.
|
|
364
|
+
await this.actionExecutor.assertPageAllowed(page, browserOptions);
|
|
361
365
|
session = this.store.create({
|
|
362
366
|
ownerId,
|
|
363
367
|
page,
|
|
@@ -371,8 +375,9 @@ export class BrowserSessionTool {
|
|
|
371
375
|
maxPerOwner: ownerId.startsWith(REST_OWNER_PREFIX) ? REST_MAX_SESSIONS_PER_OWNER : undefined
|
|
372
376
|
});
|
|
373
377
|
} catch (error) {
|
|
374
|
-
// A cap refusal arrives with a live page in
|
|
375
|
-
// rethrowing, or the refused call leaks the
|
|
378
|
+
// A cap refusal, or a page the gate refused, arrives with a live page in
|
|
379
|
+
// hand. Give it back before rethrowing, or the refused call leaks the
|
|
380
|
+
// context it just pinned.
|
|
376
381
|
await releasePage();
|
|
377
382
|
throw error;
|
|
378
383
|
}
|
|
@@ -438,13 +443,29 @@ export class BrowserSessionTool {
|
|
|
438
443
|
}
|
|
439
444
|
}
|
|
440
445
|
|
|
446
|
+
/**
|
|
447
|
+
* Read from a session's page once the URL it stands on has passed the gate.
|
|
448
|
+
*
|
|
449
|
+
* A session's page outlives the call that loaded it, and between calls it
|
|
450
|
+
* can go anywhere: the last `act` clicked a link, or a script redirected.
|
|
451
|
+
* ActionExecutor.readGated holds it to the SSRF guard and the blocklist/
|
|
452
|
+
* robots gate on both sides of the read, with this call's `respect_robots`.
|
|
453
|
+
*/
|
|
454
|
+
readPage(session, params, read) {
|
|
455
|
+
return this.actionExecutor.readGated(
|
|
456
|
+
session.page,
|
|
457
|
+
{ respectRobots: params.respect_robots, tool: 'browser_session' },
|
|
458
|
+
read
|
|
459
|
+
);
|
|
460
|
+
}
|
|
461
|
+
|
|
441
462
|
async snapshotSession(params, ownerId) {
|
|
442
463
|
const session = this.requireSession(params, ownerId);
|
|
443
464
|
|
|
444
|
-
const snapshot = await captureSnapshot(session.page, {
|
|
465
|
+
const snapshot = await this.readPage(session, params, () => captureSnapshot(session.page, {
|
|
445
466
|
interactiveOnly: params.interactive_only,
|
|
446
467
|
maxNodes: params.max_nodes
|
|
447
|
-
});
|
|
468
|
+
}));
|
|
448
469
|
|
|
449
470
|
this.store.touch(session, session.page.url());
|
|
450
471
|
return { success: true, operation: 'snapshot', ...sessionInfo(session), snapshot };
|
|
@@ -473,9 +494,11 @@ export class BrowserSessionTool {
|
|
|
473
494
|
}
|
|
474
495
|
|
|
475
496
|
// Every `navigate` in here re-runs the SSRF guard and the blocklist/robots
|
|
476
|
-
// gate inside executeNavigateAction
|
|
477
|
-
//
|
|
478
|
-
//
|
|
497
|
+
// gate inside executeNavigateAction, and every action is bracketed by the
|
|
498
|
+
// same check on wherever the page has moved (executeGatedAction) — the
|
|
499
|
+
// reason the gate is not repeated here. A long-lived session is a
|
|
500
|
+
// repeatable navigation primitive, so those checks are what stop it
|
|
501
|
+
// becoming an SSRF hop.
|
|
479
502
|
const result = await this.actionExecutor.executeActionsOnPage(session.page, params.actions, {
|
|
480
503
|
continueOnError: params.continue_on_error,
|
|
481
504
|
timeout: params.timeout,
|
|
@@ -503,8 +526,10 @@ export class BrowserSessionTool {
|
|
|
503
526
|
|
|
504
527
|
async readSession(params, ownerId) {
|
|
505
528
|
const session = this.requireSession(params, ownerId);
|
|
506
|
-
const url =
|
|
507
|
-
|
|
529
|
+
const { url, html } = await this.readPage(session, params, async () => ({
|
|
530
|
+
url: session.page.url(),
|
|
531
|
+
html: await session.page.content()
|
|
532
|
+
}));
|
|
508
533
|
|
|
509
534
|
const options = {};
|
|
510
535
|
if (params.formats.includes('markdown')) options.outputFormat = 'markdown';
|
|
@@ -564,12 +589,12 @@ export class BrowserSessionTool {
|
|
|
564
589
|
async screenshotSession(params, ownerId) {
|
|
565
590
|
const session = this.requireSession(params, ownerId);
|
|
566
591
|
|
|
567
|
-
const shot = await this.actionExecutor.captureScreenshot(session.page, {
|
|
592
|
+
const shot = await this.readPage(session, params, () => this.actionExecutor.captureScreenshot(session.page, {
|
|
568
593
|
fullPage: params.full_page,
|
|
569
594
|
format: params.format,
|
|
570
595
|
quality: params.quality,
|
|
571
596
|
selector: params.selector
|
|
572
|
-
});
|
|
597
|
+
}));
|
|
573
598
|
|
|
574
599
|
this.store.touch(session, session.page.url());
|
|
575
600
|
// The actionId is what lets the server publish the image as a
|
|
@@ -14,7 +14,7 @@ import { stealthDocumentVerdict } from '../../utils/stealthVerdict.js';
|
|
|
14
14
|
import { elementText } from '../../utils/elementText.js';
|
|
15
15
|
import { pageTitle } from '../../utils/pageTitle.js';
|
|
16
16
|
import { htmlToMarkdown } from '../../utils/htmlToMarkdown.js';
|
|
17
|
-
import { resolveStealthEngine } from '../../core/StealthBrowserManager.js';
|
|
17
|
+
import { resolveStealthEngine, redactProxyEntry, assertProxyEngineAllowed } from '../../core/StealthBrowserManager.js';
|
|
18
18
|
import { actionQueue as sharedActionQueue } from '../../core/browser/actionQueue.js';
|
|
19
19
|
|
|
20
20
|
// Recording / replay helpers
|
|
@@ -27,6 +27,20 @@ import {
|
|
|
27
27
|
recordedEntryToAction
|
|
28
28
|
} from './scrapeWithActions/recorder.js';
|
|
29
29
|
|
|
30
|
+
// metadata.browserOptions echoes the merged options, and a proxy entry carries
|
|
31
|
+
// its password.
|
|
32
|
+
function redactProxyCredentials(browserOptions) {
|
|
33
|
+
const proxies = browserOptions.proxyRotation?.proxies;
|
|
34
|
+
if (!proxies) return browserOptions;
|
|
35
|
+
return {
|
|
36
|
+
...browserOptions,
|
|
37
|
+
proxyRotation: {
|
|
38
|
+
...browserOptions.proxyRotation,
|
|
39
|
+
proxies: proxies.map(redactProxyEntry)
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
30
44
|
// Action schemas (re-using from ActionExecutor but with tool-specific additions)
|
|
31
45
|
const BaseActionSchema = z.object({
|
|
32
46
|
type: z.string(),
|
|
@@ -203,7 +217,15 @@ const ScrapeWithActionsSchema = z.object({
|
|
|
203
217
|
// Answer a cookie/consent wall (autoconsent) after the initial load and
|
|
204
218
|
// after each navigate action: "reject" opts out, "accept" opts in. Off by
|
|
205
219
|
// default so a chain that clicks the banner itself keeps working.
|
|
206
|
-
consent: z.enum(['off', 'reject', 'accept']).default('off')
|
|
220
|
+
consent: z.enum(['off', 'reject', 'accept']).default('off'),
|
|
221
|
+
// The caller's own proxies for a stealth chain — the shape and parsing of
|
|
222
|
+
// stealth_mode's stealthConfig.proxyRotation. Refused without stealth:true
|
|
223
|
+
// (see executeSession); CrawlForge supplies none.
|
|
224
|
+
proxyRotation: z.object({
|
|
225
|
+
enabled: z.boolean().default(false),
|
|
226
|
+
proxies: z.array(z.string()).optional(),
|
|
227
|
+
rotationInterval: z.number().default(300000)
|
|
228
|
+
}).optional()
|
|
207
229
|
}).optional(),
|
|
208
230
|
|
|
209
231
|
// Content extraction options
|
|
@@ -425,6 +447,8 @@ export class ScrapeWithActionsTool extends EventEmitter {
|
|
|
425
447
|
const warnings = [];
|
|
426
448
|
if (browserOptions.stealth) {
|
|
427
449
|
const resolved = await resolveStealthEngine(browserOptions.engine);
|
|
450
|
+
// Refused on the engine that will run, before the chain starts.
|
|
451
|
+
assertProxyEngineAllowed(resolved.engine, browserOptions);
|
|
428
452
|
browserOptions.stealthMode = { enabled: true, engine: resolved.engine };
|
|
429
453
|
if (resolved.fallbackWarning) warnings.push(resolved.fallbackWarning);
|
|
430
454
|
} else if (browserOptions.engine === 'camoufox') {
|
|
@@ -439,6 +463,15 @@ export class ScrapeWithActionsTool extends EventEmitter {
|
|
|
439
463
|
'to use the standard Chromium browser.'
|
|
440
464
|
);
|
|
441
465
|
}
|
|
466
|
+
if (browserOptions.proxyRotation?.enabled && !browserOptions.stealth) {
|
|
467
|
+
// Only the stealth browser takes a proxy; the standard pool would run
|
|
468
|
+
// the chain from this server's own address while the caller believes
|
|
469
|
+
// it is proxied.
|
|
470
|
+
throw new Error(
|
|
471
|
+
'browserOptions.proxyRotation requires browserOptions.stealth:true — only the stealth ' +
|
|
472
|
+
'browser routes through a proxy. Set stealth:true, or drop proxyRotation.'
|
|
473
|
+
);
|
|
474
|
+
}
|
|
442
475
|
|
|
443
476
|
// The robots override rides with the browser options so ActionExecutor's
|
|
444
477
|
// gate sees it on the initial load and on every `navigate` action.
|
|
@@ -534,7 +567,7 @@ export class ScrapeWithActionsTool extends EventEmitter {
|
|
|
534
567
|
text: finalContent?.content?.text || '',
|
|
535
568
|
html: chainResult?.finalHtml || '',
|
|
536
569
|
status: chainResult?.navigationStatus ?? null
|
|
537
|
-
}, { allowEmpty: true });
|
|
570
|
+
}, { allowEmpty: true, fetcher: browserOptions.stealthMode?.enabled ? 'the stealth browser' : 'the browser' });
|
|
538
571
|
|
|
539
572
|
return {
|
|
540
573
|
success: chainResult.success && verdict.success,
|
|
@@ -553,6 +586,9 @@ export class ScrapeWithActionsTool extends EventEmitter {
|
|
|
553
586
|
// The landing page's consent result; each navigate action's own is on
|
|
554
587
|
// its result. Absent when consent handling is off.
|
|
555
588
|
...(chainResult.consent ? { consent: chainResult.consent } : {}),
|
|
589
|
+
// Every navigation of the reported attempt — [0] the initial load, then
|
|
590
|
+
// each navigate action — with its status and any wall it reached.
|
|
591
|
+
navigations: chainResult.navigations || [],
|
|
556
592
|
|
|
557
593
|
actionResults,
|
|
558
594
|
attempt: chainResult.attempt ?? attempts.length,
|
|
@@ -576,7 +612,7 @@ export class ScrapeWithActionsTool extends EventEmitter {
|
|
|
576
612
|
replayedFrom: params.replayRecording || undefined,
|
|
577
613
|
|
|
578
614
|
metadata: {
|
|
579
|
-
browserOptions,
|
|
615
|
+
browserOptions: redactProxyCredentials(browserOptions),
|
|
580
616
|
formAutoFillApplied: !!params.formAutoFill,
|
|
581
617
|
intermediateStatesCount: intermediateStates.length,
|
|
582
618
|
screenshotsCount: sessionContext.screenshots.length,
|
|
@@ -10,6 +10,7 @@ import { config as appConfig } from '../../../constants/config.js';
|
|
|
10
10
|
import { ssrfGuard, isSsrfError } from '../../../utils/ssrfGuard.js';
|
|
11
11
|
import { noteRetryAfter } from '../../../utils/hostRateLimiter.js';
|
|
12
12
|
import { preflightFetch } from '../../../utils/robotsGate.js';
|
|
13
|
+
import { fetchResigned } from '../../../utils/resignedFetch.js';
|
|
13
14
|
import { htmlToMarkdown } from '../../../utils/htmlToMarkdown.js';
|
|
14
15
|
import { elementText } from '../../../utils/elementText.js';
|
|
15
16
|
import { pageTitle } from '../../../utils/pageTitle.js';
|
|
@@ -31,9 +32,10 @@ export async function fetchUrl(url, options = {}) {
|
|
|
31
32
|
const controller = new AbortController();
|
|
32
33
|
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
|
33
34
|
try {
|
|
34
|
-
const response = await
|
|
35
|
+
const response = await fetchResigned(url, {
|
|
35
36
|
signal: controller.signal,
|
|
36
37
|
headers: { ...gate.headers, ...headers },
|
|
38
|
+
onRedirect: gate.onRedirect,
|
|
37
39
|
...guard
|
|
38
40
|
});
|
|
39
41
|
if (response.status === 429 || response.status === 503) {
|
|
@@ -8,6 +8,7 @@ import { config } from '../../constants/config.js';
|
|
|
8
8
|
import { ssrfGuard, isSsrfError } from '../../utils/ssrfGuard.js';
|
|
9
9
|
import { noteRetryAfter } from '../../utils/hostRateLimiter.js';
|
|
10
10
|
import { preflightFetch } from '../../utils/robotsGate.js';
|
|
11
|
+
import { fetchResigned } from '../../utils/resignedFetch.js';
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
14
|
* Fetch a URL with a configurable timeout and body-size cap.
|
|
@@ -51,12 +52,13 @@ export async function fetchWithTimeout(url, options = {}) {
|
|
|
51
52
|
try {
|
|
52
53
|
let response;
|
|
53
54
|
try {
|
|
54
|
-
response = await
|
|
55
|
+
response = await fetchResigned(url, {
|
|
55
56
|
signal: controller.signal,
|
|
56
57
|
headers: {
|
|
57
58
|
...gate.headers,
|
|
58
59
|
...headers
|
|
59
60
|
},
|
|
61
|
+
onRedirect: gate.onRedirect,
|
|
60
62
|
...guard
|
|
61
63
|
});
|
|
62
64
|
} catch (error) {
|
|
@@ -384,7 +384,8 @@ export class MapSiteTool {
|
|
|
384
384
|
try {
|
|
385
385
|
const response = await safeFetch(url, {
|
|
386
386
|
signal: controller.signal,
|
|
387
|
-
headers: gate.headers
|
|
387
|
+
headers: gate.headers,
|
|
388
|
+
onRedirect: gate.onRedirect
|
|
388
389
|
});
|
|
389
390
|
clearTimeout(timeoutId);
|
|
390
391
|
return response;
|
|
@@ -176,7 +176,7 @@ export class ExtractContentTool {
|
|
|
176
176
|
enableImages: options.preserveImageInfo,
|
|
177
177
|
captureScreenshot: false
|
|
178
178
|
}
|
|
179
|
-
});
|
|
179
|
+
}, gate);
|
|
180
180
|
|
|
181
181
|
if (!browserResult.success) {
|
|
182
182
|
throw new Error(`Browser processing failed: ${browserResult.error}`);
|
|
@@ -188,6 +188,7 @@ export class ExtractContentTool {
|
|
|
188
188
|
// Simple HTTP fetch
|
|
189
189
|
const response = await safeFetch(url, {
|
|
190
190
|
headers: { ...gate.headers },
|
|
191
|
+
onRedirect: gate.onRedirect,
|
|
191
192
|
signal: AbortSignal.timeout(15000)
|
|
192
193
|
});
|
|
193
194
|
|