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
|
@@ -44,8 +44,8 @@ export const FALLBACK_HINTS = Object.freeze({
|
|
|
44
44
|
});
|
|
45
45
|
|
|
46
46
|
/**
|
|
47
|
-
* The second-stage hint (Phase 3, 3.4). A `scrape`
|
|
48
|
-
* `escalated: true` has ALREADY run the stealth browser on this URL, so the
|
|
47
|
+
* The second-stage hint (Phase 3, 3.4). A `scrape` or `extract_embedded_state`
|
|
48
|
+
* that came back `escalated: true` has ALREADY run the stealth browser on this URL, so the
|
|
49
49
|
* ordinary hint would send the model back to a tool that has just failed.
|
|
50
50
|
* What is left is a region-specific block, which `localization` can change,
|
|
51
51
|
* or a block that is final: an Akamai-style TLS-level wall is beaten with
|
|
@@ -56,9 +56,12 @@ export const SCRAPE_ESCALATED_HINT =
|
|
|
56
56
|
'If the block is regional, set a country with localization and try once more; otherwise the block is final - ' +
|
|
57
57
|
'a TLS-level wall needs residential proxies, which CrawlForge does not offer. Get the content from another source.';
|
|
58
58
|
|
|
59
|
-
/** The
|
|
59
|
+
/** The tools whose escalate:true runs the stealth stage itself (Phase 3; extract_embedded_state since plan Phase 3.2). */
|
|
60
|
+
const ESCALATING_TOOLS = new Set(['scrape', 'extract_embedded_state']);
|
|
61
|
+
|
|
62
|
+
/** The hint for this result: the tool's, unless the call already escalated. */
|
|
60
63
|
function hintFor(toolName, parsed) {
|
|
61
|
-
if (toolName
|
|
64
|
+
if (ESCALATING_TOOLS.has(toolName) && parsed?.escalated === true) return SCRAPE_ESCALATED_HINT;
|
|
62
65
|
return FALLBACK_HINTS[toolName];
|
|
63
66
|
}
|
|
64
67
|
|
|
@@ -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
|
|
|
@@ -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
|
|
|
@@ -110,8 +111,10 @@ const BrowserSessionSchema = z.object({
|
|
|
110
111
|
timeout: z.number().min(10000).max(120000).default(30000),
|
|
111
112
|
|
|
112
113
|
// Applies to the call it is sent on: on `open` to the first load, on `act` to
|
|
113
|
-
// every navigate in that call
|
|
114
|
-
//
|
|
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.
|
|
115
118
|
respect_robots: z.boolean().optional(),
|
|
116
119
|
|
|
117
120
|
// snapshot
|
|
@@ -207,7 +210,10 @@ export class BrowserSessionTool {
|
|
|
207
210
|
extractContentTool = null,
|
|
208
211
|
store = null,
|
|
209
212
|
storeOptions = {},
|
|
210
|
-
enableLogging = true
|
|
213
|
+
enableLogging = true,
|
|
214
|
+
// The process-wide browser-slot limiter shared with scrape_with_actions.
|
|
215
|
+
// Injectable for tests only.
|
|
216
|
+
actionQueue = sharedActionQueue
|
|
211
217
|
} = options;
|
|
212
218
|
|
|
213
219
|
// An injected executor belongs to whoever built it (server.js hands us
|
|
@@ -218,6 +224,7 @@ export class BrowserSessionTool {
|
|
|
218
224
|
this.extractContentTool = extractContentTool || new ExtractContentTool();
|
|
219
225
|
this.storeOptions = storeOptions;
|
|
220
226
|
this.store = store || new BrowserSessionStore(storeOptions);
|
|
227
|
+
this.actionQueue = actionQueue;
|
|
221
228
|
}
|
|
222
229
|
|
|
223
230
|
async execute(params) {
|
|
@@ -337,11 +344,24 @@ export class BrowserSessionTool {
|
|
|
337
344
|
// only then creates a page and navigates — closing the page itself if any
|
|
338
345
|
// of that fails. That is the whole of 2.5's gating on `open`, which is why
|
|
339
346
|
// this is not page.goto() behind a gate written here.
|
|
340
|
-
|
|
347
|
+
//
|
|
348
|
+
// Page creation and the first navigation take a slot from the browser
|
|
349
|
+
// budget scrape_with_actions draws on, so a burst of opens queues behind
|
|
350
|
+
// (and alongside) action chains instead of piling onto the box. The slot is
|
|
351
|
+
// released as soon as the page is up — a live session must NOT hold it for
|
|
352
|
+
// its TTL, or three open sessions would starve every scrape_with_actions
|
|
353
|
+
// call for up to an hour. How many sessions may stay open is the store's
|
|
354
|
+
// per-owner and total caps below, which still refuse rather than queue.
|
|
355
|
+
const page = await this.actionQueue.run(
|
|
356
|
+
() => this.actionExecutor.initializePage(params.url, browserOptions),
|
|
357
|
+
{ label: 'browser_session open' }
|
|
358
|
+
);
|
|
341
359
|
const releasePage = this.releaser(page, params.stealth);
|
|
342
360
|
|
|
343
361
|
let session;
|
|
344
362
|
try {
|
|
363
|
+
// The load was gated; a script may have moved the page on since.
|
|
364
|
+
await this.actionExecutor.assertPageAllowed(page, browserOptions);
|
|
345
365
|
session = this.store.create({
|
|
346
366
|
ownerId,
|
|
347
367
|
page,
|
|
@@ -355,8 +375,9 @@ export class BrowserSessionTool {
|
|
|
355
375
|
maxPerOwner: ownerId.startsWith(REST_OWNER_PREFIX) ? REST_MAX_SESSIONS_PER_OWNER : undefined
|
|
356
376
|
});
|
|
357
377
|
} catch (error) {
|
|
358
|
-
// A cap refusal arrives with a live page in
|
|
359
|
-
// 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.
|
|
360
381
|
await releasePage();
|
|
361
382
|
throw error;
|
|
362
383
|
}
|
|
@@ -422,13 +443,29 @@ export class BrowserSessionTool {
|
|
|
422
443
|
}
|
|
423
444
|
}
|
|
424
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
|
+
|
|
425
462
|
async snapshotSession(params, ownerId) {
|
|
426
463
|
const session = this.requireSession(params, ownerId);
|
|
427
464
|
|
|
428
|
-
const snapshot = await captureSnapshot(session.page, {
|
|
465
|
+
const snapshot = await this.readPage(session, params, () => captureSnapshot(session.page, {
|
|
429
466
|
interactiveOnly: params.interactive_only,
|
|
430
467
|
maxNodes: params.max_nodes
|
|
431
|
-
});
|
|
468
|
+
}));
|
|
432
469
|
|
|
433
470
|
this.store.touch(session, session.page.url());
|
|
434
471
|
return { success: true, operation: 'snapshot', ...sessionInfo(session), snapshot };
|
|
@@ -457,9 +494,11 @@ export class BrowserSessionTool {
|
|
|
457
494
|
}
|
|
458
495
|
|
|
459
496
|
// Every `navigate` in here re-runs the SSRF guard and the blocklist/robots
|
|
460
|
-
// gate inside executeNavigateAction
|
|
461
|
-
//
|
|
462
|
-
//
|
|
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.
|
|
463
502
|
const result = await this.actionExecutor.executeActionsOnPage(session.page, params.actions, {
|
|
464
503
|
continueOnError: params.continue_on_error,
|
|
465
504
|
timeout: params.timeout,
|
|
@@ -487,8 +526,10 @@ export class BrowserSessionTool {
|
|
|
487
526
|
|
|
488
527
|
async readSession(params, ownerId) {
|
|
489
528
|
const session = this.requireSession(params, ownerId);
|
|
490
|
-
const url =
|
|
491
|
-
|
|
529
|
+
const { url, html } = await this.readPage(session, params, async () => ({
|
|
530
|
+
url: session.page.url(),
|
|
531
|
+
html: await session.page.content()
|
|
532
|
+
}));
|
|
492
533
|
|
|
493
534
|
const options = {};
|
|
494
535
|
if (params.formats.includes('markdown')) options.outputFormat = 'markdown';
|
|
@@ -548,12 +589,12 @@ export class BrowserSessionTool {
|
|
|
548
589
|
async screenshotSession(params, ownerId) {
|
|
549
590
|
const session = this.requireSession(params, ownerId);
|
|
550
591
|
|
|
551
|
-
const shot = await this.actionExecutor.captureScreenshot(session.page, {
|
|
592
|
+
const shot = await this.readPage(session, params, () => this.actionExecutor.captureScreenshot(session.page, {
|
|
552
593
|
fullPage: params.full_page,
|
|
553
594
|
format: params.format,
|
|
554
595
|
quality: params.quality,
|
|
555
596
|
selector: params.selector
|
|
556
|
-
});
|
|
597
|
+
}));
|
|
557
598
|
|
|
558
599
|
this.store.touch(session, session.page.url());
|
|
559
600
|
// The actionId is what lets the server publish the image as a
|
|
@@ -15,6 +15,7 @@ import { elementText } from '../../utils/elementText.js';
|
|
|
15
15
|
import { pageTitle } from '../../utils/pageTitle.js';
|
|
16
16
|
import { htmlToMarkdown } from '../../utils/htmlToMarkdown.js';
|
|
17
17
|
import { resolveStealthEngine } from '../../core/StealthBrowserManager.js';
|
|
18
|
+
import { actionQueue as sharedActionQueue } from '../../core/browser/actionQueue.js';
|
|
18
19
|
|
|
19
20
|
// Recording / replay helpers
|
|
20
21
|
import {
|
|
@@ -198,7 +199,11 @@ const ScrapeWithActionsSchema = z.object({
|
|
|
198
199
|
// — the standard pool is Chromium and camoufox exists only on the stealth
|
|
199
200
|
// path — so "camoufox" without stealth is refused rather than quietly
|
|
200
201
|
// downgraded (see executeSession).
|
|
201
|
-
engine: z.enum(['auto', 'chromium', 'camoufox', 'playwright']).default('auto')
|
|
202
|
+
engine: z.enum(['auto', 'chromium', 'camoufox', 'playwright']).default('auto'),
|
|
203
|
+
// Answer a cookie/consent wall (autoconsent) after the initial load and
|
|
204
|
+
// after each navigate action: "reject" opts out, "accept" opts in. Off by
|
|
205
|
+
// default so a chain that clicks the banner itself keeps working.
|
|
206
|
+
consent: z.enum(['off', 'reject', 'accept']).default('off')
|
|
202
207
|
}).optional(),
|
|
203
208
|
|
|
204
209
|
// Content extraction options
|
|
@@ -255,7 +260,9 @@ export class ScrapeWithActionsTool extends EventEmitter {
|
|
|
255
260
|
extractContentTool = null,
|
|
256
261
|
enableLogging = true,
|
|
257
262
|
enableCaching = false,
|
|
258
|
-
|
|
263
|
+
// The process-wide browser-slot limiter shared with browser_session open.
|
|
264
|
+
// Injectable for tests only; CRAWLFORGE_MAX_ACTION_SESSIONS sizes it.
|
|
265
|
+
actionQueue = sharedActionQueue,
|
|
259
266
|
defaultBrowserOptions = {
|
|
260
267
|
viewportWidth: 1280,
|
|
261
268
|
viewportHeight: 720,
|
|
@@ -274,7 +281,7 @@ export class ScrapeWithActionsTool extends EventEmitter {
|
|
|
274
281
|
this.extractContentTool = extractContentTool || new ExtractContentTool();
|
|
275
282
|
this.enableLogging = enableLogging;
|
|
276
283
|
this.enableCaching = enableCaching;
|
|
277
|
-
this.
|
|
284
|
+
this.actionQueue = actionQueue;
|
|
278
285
|
this.defaultBrowserOptions = defaultBrowserOptions;
|
|
279
286
|
|
|
280
287
|
// Active sessions tracking
|
|
@@ -322,69 +329,80 @@ export class ScrapeWithActionsTool extends EventEmitter {
|
|
|
322
329
|
|
|
323
330
|
this.stats.totalSessions++;
|
|
324
331
|
const sessionId = this.generateSessionId();
|
|
325
|
-
const
|
|
332
|
+
const enqueuedAt = Date.now();
|
|
326
333
|
|
|
327
334
|
if (this.enableLogging) {
|
|
328
335
|
console.error(`Starting scrape session ${sessionId} with ${validated.actions.length} actions on ${validated.url}`);
|
|
329
336
|
}
|
|
330
337
|
|
|
331
|
-
//
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
id: sessionId,
|
|
339
|
-
url: validated.url,
|
|
340
|
-
startTime,
|
|
341
|
-
params: validated,
|
|
342
|
-
states: [],
|
|
343
|
-
screenshots: [],
|
|
344
|
-
actionResults: [],
|
|
345
|
-
errors: [],
|
|
346
|
-
status: 'initializing'
|
|
347
|
-
};
|
|
338
|
+
// The whole chain runs inside a slot of the shared browser budget. Over
|
|
339
|
+
// the limit a call waits for one rather than being refused, and fails
|
|
340
|
+
// only after CRAWLFORGE_ACTION_QUEUE_TIMEOUT_MS with the queue depth.
|
|
341
|
+
return await this.actionQueue.run(
|
|
342
|
+
() => this.runSession(sessionId, validated, enqueuedAt),
|
|
343
|
+
{ label: 'scrape_with_actions' }
|
|
344
|
+
);
|
|
348
345
|
|
|
349
|
-
|
|
350
|
-
this.
|
|
346
|
+
} catch (error) {
|
|
347
|
+
this.log('error', `Scrape with actions failed: ${error.message}`);
|
|
348
|
+
throw new Error(`Scrape with actions failed: ${error.message}`);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
351
|
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
});
|
|
371
|
-
}
|
|
352
|
+
async runSession(sessionId, validated, enqueuedAt) {
|
|
353
|
+
// The clock starts once the slot is held, so executionTime and the
|
|
354
|
+
// recording's timings measure the run, not the wait before it.
|
|
355
|
+
const startTime = Date.now();
|
|
356
|
+
const queuedMs = startTime - enqueuedAt;
|
|
357
|
+
|
|
358
|
+
// Create session context
|
|
359
|
+
const sessionContext = {
|
|
360
|
+
id: sessionId,
|
|
361
|
+
url: validated.url,
|
|
362
|
+
startTime,
|
|
363
|
+
params: validated,
|
|
364
|
+
states: [],
|
|
365
|
+
screenshots: [],
|
|
366
|
+
actionResults: [],
|
|
367
|
+
errors: [],
|
|
368
|
+
status: 'initializing'
|
|
369
|
+
};
|
|
372
370
|
|
|
373
|
-
|
|
374
|
-
|
|
371
|
+
this.activeSessions.set(sessionId, sessionContext);
|
|
372
|
+
this.emit('sessionStarted', sessionContext);
|
|
375
373
|
|
|
376
|
-
|
|
374
|
+
try {
|
|
375
|
+
const result = await this.executeSession(sessionContext);
|
|
376
|
+
result.queued_ms = queuedMs;
|
|
377
377
|
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
378
|
+
this.stats.successfulSessions++;
|
|
379
|
+
this.stats.totalActions += validated.actions.length;
|
|
380
|
+
this.stats.successfulActions += result.actionResults.filter(r => r.success).length;
|
|
381
|
+
this.stats.failedActions += result.actionResults.filter(r => !r.success).length;
|
|
382
|
+
|
|
383
|
+
const executionTime = Date.now() - startTime;
|
|
384
|
+
this.updateAverageSessionTime(executionTime);
|
|
385
|
+
this.updateAverageActionsPerSession(validated.actions.length);
|
|
386
|
+
this.updateStats();
|
|
387
|
+
|
|
388
|
+
if (this.enableCaching) {
|
|
389
|
+
this.sessionResults.set(sessionId, {
|
|
390
|
+
result,
|
|
391
|
+
timestamp: Date.now(),
|
|
392
|
+
ttl: 3600000
|
|
393
|
+
});
|
|
383
394
|
}
|
|
384
395
|
|
|
396
|
+
this.activeSessions.delete(sessionId);
|
|
397
|
+
this.emit('sessionCompleted', result);
|
|
398
|
+
|
|
399
|
+
return result;
|
|
400
|
+
|
|
385
401
|
} catch (error) {
|
|
386
|
-
this.
|
|
387
|
-
|
|
402
|
+
this.stats.failedSessions++;
|
|
403
|
+
this.activeSessions.delete(sessionId);
|
|
404
|
+
this.emit('sessionFailed', { sessionId, url: validated.url, error });
|
|
405
|
+
throw error;
|
|
388
406
|
}
|
|
389
407
|
}
|
|
390
408
|
|
|
@@ -532,6 +550,9 @@ export class ScrapeWithActionsTool extends EventEmitter {
|
|
|
532
550
|
// was chosen (an "auto" that fell back to Chromium says so here).
|
|
533
551
|
engine: browserOptions.stealthMode?.engine || 'chromium',
|
|
534
552
|
...(warnings.length ? { warnings } : {}),
|
|
553
|
+
// The landing page's consent result; each navigate action's own is on
|
|
554
|
+
// its result. Absent when consent handling is off.
|
|
555
|
+
...(chainResult.consent ? { consent: chainResult.consent } : {}),
|
|
535
556
|
|
|
536
557
|
actionResults,
|
|
537
558
|
attempt: chainResult.attempt ?? attempts.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
|
|