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.
Files changed (42) hide show
  1. package/README.md +1 -1
  2. package/package.json +4 -3
  3. package/server.js +68 -22
  4. package/src/cli/commands/stealth.js +2 -2
  5. package/src/core/ActionExecutor.js +194 -64
  6. package/src/core/AuthManager.js +12 -0
  7. package/src/core/LLMsTxtAnalyzer.js +1 -0
  8. package/src/core/ResearchOrchestrator.js +10 -4
  9. package/src/core/StealthBrowserManager.js +29 -6
  10. package/src/core/browser/actionQueue.js +84 -0
  11. package/src/core/browser/consent.js +182 -0
  12. package/src/core/browser/settle.js +141 -0
  13. package/src/core/browser/snapshot.js +241 -51
  14. package/src/core/browser/windowState.js +88 -0
  15. package/src/core/crawlers/BFSCrawler.js +12 -3
  16. package/src/core/processing/BrowserProcessor.js +19 -7
  17. package/src/core/processing/PDFProcessor.js +15 -6
  18. package/src/server/fallbackHints.js +7 -4
  19. package/src/server/inlineThreshold.js +81 -19
  20. package/src/skills/agent-skills/crawlforge-getting-started/references/credits.md +1 -1
  21. package/src/skills/agent-skills/crawlforge-structured-extraction/SKILL.md +25 -6
  22. package/src/tools/advanced/BrowserSessionTool.js +56 -15
  23. package/src/tools/advanced/ScrapeWithActionsTool.js +74 -53
  24. package/src/tools/advanced/batchScrape/worker.js +3 -1
  25. package/src/tools/basic/_fetch.js +3 -1
  26. package/src/tools/crawl/_sessionContext.js +2 -1
  27. package/src/tools/crawl/mapSite.js +2 -1
  28. package/src/tools/extract/_fetchAndParse.js +1 -0
  29. package/src/tools/extract/extractContent.js +2 -1
  30. package/src/tools/extract/extractEmbeddedState.js +320 -71
  31. package/src/tools/extract/processDocument.js +5 -3
  32. package/src/tools/llmstxt/generateLLMsTxt.js +2 -1
  33. package/src/tools/templates/ScrapeTemplateTool.js +11 -3
  34. package/src/tools/tracking/trackChanges/differ.js +1 -0
  35. package/src/utils/HumanBehaviorSimulator.js +35 -14
  36. package/src/utils/complianceAudit.js +35 -5
  37. package/src/utils/impitRung.js +16 -5
  38. package/src/utils/resignedFetch.js +165 -0
  39. package/src/utils/robotsChecker.js +6 -1
  40. package/src/utils/robotsGate.js +124 -11
  41. package/src/utils/sitemapParser.js +2 -1
  42. package/src/utils/ssrfGuard.js +88 -11
@@ -1,99 +1,348 @@
1
1
  /**
2
2
  * extract_embedded_state — return the JSON state a page already ships in its
3
- * own HTML: __NEXT_DATA__, RSC flight chunks (self.__next_f), __NUXT__,
4
- * __APOLLO_STATE__, __INITIAL_STATE__, __PRELOADED_STATE__ and
5
- * <script type="application/json"> blocks.
3
+ * own HTML: __NEXT_DATA__, RSC flight chunks (self.__next_f), __NUXT__ and
4
+ * Nuxt 3's __NUXT_DATA__, SvelteKit, __APOLLO_STATE__, __INITIAL_STATE__,
5
+ * __PRELOADED_STATE__, ytInitialData, Inertia, Shopify and
6
+ * <script type="application/json"> blocks. `find` returns the paths where a
7
+ * key lives instead of the data.
6
8
  *
7
9
  * One fetch, exact values, no LLM in the extraction path — the numbers come
8
10
  * from the site's own serialized state, so they cannot be fabricated.
11
+ *
12
+ * `escalate: true` (plan Phase 3.2) adds scrape's second stage: when the plain
13
+ * fetch is walled, the page is rendered by the same stealth escalation `scrape`
14
+ * uses (injected — this module never imports the browser) and the same parser
15
+ * runs on the rendered document. The browser also reads the framework globals
16
+ * off `window`, reported under `window_state`.
9
17
  */
10
18
 
19
+ import { STATUS_CODES } from 'node:http';
20
+ import { z } from 'zod';
11
21
  import { fetchAndParse } from './_fetchAndParse.js';
12
22
  // Both live in crawlforge-extractors so the REST API's extract_embedded_state
13
23
  // runs this exact reader — one RSC flight-stream parser, not two.
14
- import { extractEmbeddedState, selectJsonPath } from 'crawlforge-extractors';
24
+ import { extractEmbeddedState, selectJsonPath, findJsonPaths } from 'crawlforge-extractors';
25
+ import { stealthDocumentVerdict } from '../../utils/stealthVerdict.js';
26
+ import { pageTitle } from '../../utils/pageTitle.js';
27
+ import { setActualCost, markPreflightRefusal } from '../../server/requestContext.js';
28
+ import { SCRAPE_ESCALATION_SHAPE, SCRAPE_ESCALATION_CREDITS, aBrowserMightPass } from '../scrape/escalation.js';
29
+ import { WINDOW_STATE_NOTE } from '../../core/browser/windowState.js';
30
+
31
+ /** The table price in AuthManager.getToolCost; escalation adds SCRAPE_ESCALATION_CREDITS. */
32
+ const EMBEDDED_STATE_BASE_CREDITS = 2;
15
33
 
16
34
  // Above this, an unscoped result is big enough to be a problem for the caller
17
- // (context window, transport) rather than just large. Warn — never truncate:
18
- // a half-serialized object is worse than a big one, and `path` already gives
19
- // the caller an exact way to ask for less.
35
+ // (context window, transport) rather than just large. Warn with a ready path:
36
+ // over max_inline_chars the result comes back as a preview plus a
37
+ // result_handle (src/server/inlineThreshold.js), and `path`, `keys_only` or
38
+ // `find` ask for less up front.
20
39
  const LARGE_RESULT_BYTES = 256_000;
21
40
 
41
+ // A wall, as opposed to a missing page or a broken server (G4: a 404 or a 5xx
42
+ // never escalates — see aBrowserMightPass).
43
+ const WALL_STATUSES = new Set([403, 429, 444]);
44
+
22
45
  const BLOCKED_HINT =
23
46
  'stealth_mode operation:"scrape" renders the page in a browser and returns the HTML; the embedded state is in the returned html. Do not repeat this call with a different path.';
24
47
 
48
+ const ESCALATE_HINT =
49
+ 'Call extract_embedded_state again with escalate:true - the stealth browser re-reads the page and the same parser runs on it (projected 7 credits, charged 2 if the plain fetch gets through). Do not repeat this call with a different path.';
50
+
51
+ const STAGE_DID_NOT_RUN_HINT =
52
+ 'The stealth stage did not run (see warnings) - retry once with escalate:true; stealth_mode drives the same browser, so it is not a way around this. Do not repeat this call with a different path.';
53
+
54
+ /** The tool's own params, in tools/list order; server.js adds the compliance and inline-limit ones. */
55
+ export const EMBEDDED_STATE_INPUT_SHAPE = {
56
+ url: z.string().url().describe('The URL to read embedded state from'),
57
+ path: z.string().optional().describe('Return only this subtree instead of the whole payload. Dotted keys and array indexes, e.g. "next_data.props.pageProps" or "next_f[0].f" — not JSONPath (no wildcards, filters or recursion). State payloads are routinely over a megabyte; scope them.'),
58
+ keys_only: z.boolean().optional().default(false).describe('Return `keys` instead of `data`: the first two levels of keys of the selected data (after `path`), each value replaced by its type ("object", "array(<n>)", "string", "number", "boolean", "null"); an array shows its length and its first item. Cheap discovery before choosing a path. Default: false'),
59
+ find: z.string().min(1).max(100).optional().describe('Return `matches` instead of `data`: every property with this key name (case-insensitive) anywhere in the selected data (after `path`), in document order, each as {path, preview} with the first 200 characters of its value - at most 50, with `matches_total` and `matches_truncated`. Each match path already starts with `path`, so it can be passed straight back as `path`. Discover where a field lives without downloading the payload. Cannot be combined with keys_only'),
60
+ raw: z.boolean().optional().default(false).describe('Also keep the undecoded __NUXT_DATA__ devalue array under json_scripts, beside the decoded nuxt_data. Default: false'),
61
+ escalate: z.boolean().optional().default(false).describe('When the plain fetch comes back blocked (403/429/challenge page, or an empty shell with no state), re-read the page once in the stealth browser and run the same parser on the rendered document; the browser also reads the framework globals off window (window_state). Under escalate_engine "auto" a Chrome TLS handshake with the honest CrawlForge User-Agent (impit) is tried first, and the browser runs only when that does not get the page. A 404 or 5xx never escalates. Projected at 2+5; the actual charge stays at 2 when the plain fetch succeeded. Default: false'),
62
+ escalate_engine: SCRAPE_ESCALATION_SHAPE.escalate_engine,
63
+ wait_for: z.number().min(0).max(30000).optional().describe('Escalated render only: extra wait after page load, in ms — for state assigned after DOMContentLoaded. Ignored without escalation')
64
+ };
65
+
66
+ /** "object" | "array(<n>)" | "string" | "number" | "boolean" | "null" */
67
+ function label(value) {
68
+ if (value === null) return 'null';
69
+ if (Array.isArray(value)) return `array(${value.length})`;
70
+ return typeof value;
71
+ }
72
+
25
73
  /**
26
- * @param {{ url: string, path?: string, user_agent?: string, respect_robots?: boolean }} params
74
+ * The keys of `value`, `depth` levels deep, every value replaced by its type.
75
+ * Shared shape with the REST route — keep the two in step.
76
+ * @param {unknown} value
77
+ * @param {number} depth
27
78
  */
28
- export async function extractEmbeddedStateHandler({ url, path, user_agent, respect_robots }) {
29
- try {
30
- // The raw `html` is used, not `$`: fetchAndParse strips <script> from the
31
- // parsed tree by default, and every source here lives in a script tag.
32
- const { html, finalUrl, warnings: fetchWarnings } = await fetchAndParse(url, {
33
- userAgent: user_agent,
34
- respectRobots: respect_robots,
35
- tool: 'extract_embedded_state'
36
- });
37
-
38
- const state = extractEmbeddedState(html);
39
- const warnings = [...fetchWarnings, ...state.warnings];
40
-
41
- if (state.found.length === 0) {
42
- warnings.push(
43
- 'No embedded state found. The page may render entirely on the client, or ship its data in a format this tool does not read.'
44
- );
79
+ export function describeKeys(value, depth) {
80
+ if (Array.isArray(value)) {
81
+ if (depth <= 1) return label(value);
82
+ return value.length > 0
83
+ ? { length: value.length, first: describeKeys(value[0], depth - 1) }
84
+ : { length: 0 };
85
+ }
86
+ if (value !== null && typeof value === 'object') {
87
+ const keys = {};
88
+ for (const [key, child] of Object.entries(value)) {
89
+ keys[key] = depth > 1 ? describeKeys(child, depth - 1) : label(child);
45
90
  }
91
+ return keys;
92
+ }
93
+ return label(value);
94
+ }
46
95
 
47
- // A path is naturally written against the payload ("props.pageProps"),
48
- // not against this tool's envelope ("next_data.props.pageProps"). When
49
- // the page carries exactly one payload and the path's root is not one of
50
- // the envelope keys, read it inside that payload and say so (R21,
51
- // 2026-09-09: four Next.js pages in a row failed on the bare path).
52
- let effectivePath = path;
53
- if (path && state.found.length === 1) {
54
- const root = path.split(/[.[]/)[0];
55
- if (root && !(root in state.data)) {
56
- effectivePath = `${state.found[0].name}.${path}`;
96
+ function httpError(status) {
97
+ return new Error(`HTTP ${status}: ${STATUS_CODES[status] ?? ''}`.trimEnd());
98
+ }
99
+
100
+ function isHttpError(status) {
101
+ return typeof status === 'number' && (status < 200 || status >= 300);
102
+ }
103
+
104
+ /**
105
+ * Whether a document is a failure for this tool. A named vendor's wall and an
106
+ * HTTP error always are. A page the verdict calls an empty shell or an error
107
+ * placeholder is one only when it carried no state: a client-rendered page
108
+ * that ships nothing but __NEXT_DATA__ is exactly what this tool reads. Shared
109
+ * rule with the REST route — keep the two in step.
110
+ */
111
+ function failed(verdict, status, gotState) {
112
+ if (verdict.blocked || isHttpError(status)) return true;
113
+ return !verdict.success && !gotState;
114
+ }
115
+
116
+ /**
117
+ * @param {{ escalateFetch?: (args: { url: string, engine: string, respectRobots?: boolean, waitFor?: number, readWindowState?: boolean }) => Promise<object> }} [deps]
118
+ * the stealth escalation stage (server.js `stealthEscalation`); without it
119
+ * an escalation that would run is reported as unavailable
120
+ */
121
+ export function createExtractEmbeddedStateHandler({ escalateFetch } = {}) {
122
+ return async function extractEmbeddedStateHandler({
123
+ url, path, keys_only, find, raw = false, escalate, escalate_engine = 'auto', wait_for, user_agent, respect_robots
124
+ }) {
125
+ // Each replaces `data` with something else, so asking for both has no
126
+ // answer. Nothing was fetched, so - like a robots refusal - it costs nothing.
127
+ if (find !== undefined && keys_only) {
128
+ markPreflightRefusal('BAD_REQUEST');
129
+ return {
130
+ content: [{
131
+ type: 'text',
132
+ text: 'Failed to extract embedded state: find and keys_only cannot be combined - find returns matches, keys_only returns keys.\nNext step: Call again with only one of find or keys_only.'
133
+ }],
134
+ isError: true
135
+ };
136
+ }
137
+ const escalating = escalate === true;
138
+ let escalationRan = false;
139
+ // One charge report for every return path: the projection is 2+5 when
140
+ // escalating, and the stealth stage's 5 is owed only if it ran (G4).
141
+ const reportCost = () => {
142
+ if (escalating) setActualCost(EMBEDDED_STATE_BASE_CREDITS + (escalationRan ? SCRAPE_ESCALATION_CREDITS : 0));
143
+ };
144
+ try {
145
+ // The raw `html` is used, not `$`: fetchAndParse strips <script> from the
146
+ // parsed tree by default, and every source here lives in a script tag.
147
+ // A non-2xx body is read too: a wall arrives as a 403 with the challenge
148
+ // in it, and the verdict below needs both to name the vendor.
149
+ const fetched = await fetchAndParse(url, {
150
+ userAgent: user_agent,
151
+ respectRobots: respect_robots,
152
+ tool: 'extract_embedded_state',
153
+ errorDocuments: true
154
+ });
155
+ let { html, finalUrl, status } = fetched;
156
+ const warnings = [...fetched.warnings];
157
+ let state = extractEmbeddedState(html, { raw });
158
+ let verdict = stealthDocumentVerdict(
159
+ { url: finalUrl, title: pageTitle(fetched.$), text: fetched.textContent, html, status },
160
+ { fetcher: 'a plain fetch', rendered: false, contentReturned: false }
161
+ );
162
+
163
+ let escalationFields = {};
164
+ let windowState = null;
165
+ if (failed(verdict, status, state.found.length > 0)) {
166
+ // A 404 or a 5xx is not a wall: it fails as it always has, and never
167
+ // escalates — the browser would spend 5 credits to be told the same.
168
+ if (!WALL_STATUSES.has(status) && !aBrowserMightPass(verdict, status)) throw httpError(status);
169
+ // Unescalated, a wall or an empty shell is an error naming the vendor
170
+ // (the verdict does) and the one parameter that changes the outcome.
171
+ if (!escalating) {
172
+ return {
173
+ content: [{ type: 'text', text: `Failed to extract embedded state: ${verdict.error}\nNext step: ${ESCALATE_HINT}` }],
174
+ isError: true
175
+ };
176
+ }
177
+ const vendorDetected = verdict.blocked?.vendor ?? null;
178
+ if (!escalateFetch) {
179
+ warnings.push('escalate: no stealth stage is wired into this server build');
180
+ } else {
181
+ try {
182
+ const stealth = await escalateFetch({
183
+ url: finalUrl,
184
+ engine: escalate_engine,
185
+ respectRobots: respect_robots,
186
+ waitFor: wait_for || 0,
187
+ readWindowState: true
188
+ });
189
+ escalationRan = true;
190
+ const engine = stealth.engine || escalate_engine;
191
+ if (Array.isArray(stealth.warnings)) warnings.push(...stealth.warnings);
192
+ html = stealth.html || '';
193
+ finalUrl = stealth.url || finalUrl;
194
+ status = stealth.status ?? null;
195
+ // The same parser, on the document the browser ended up with.
196
+ state = extractEmbeddedState(html, { raw });
197
+ verdict = stealthDocumentVerdict(
198
+ { url: finalUrl, title: stealth.title || '', text: stealth.text || '', html, status },
199
+ { waitedMs: stealth.gracedMs || 0, fetcher: 'the stealth browser', rendered: true, contentReturned: false }
200
+ );
201
+ windowState = stealth.windowState ?? null;
202
+ if (engine === 'impit') {
203
+ warnings.push('escalate: impit (a Chrome TLS handshake, no browser) got the page, so no JavaScript ran and window_state was not read');
204
+ }
205
+ const gotIt = !failed(verdict, status, state.found.length > 0 || Object.keys(windowState ?? {}).length > 0);
206
+ warnings.push(
207
+ `escalate: the plain fetch ${vendorDetected ? `was blocked by ${vendorDetected}` : 'did not return the page'}; ` +
208
+ `${engine === 'impit' ? 'a Chrome TLS handshake (impit, honest User-Agent)' : `the ${engine} stealth browser`} ${gotIt ? 'returned it' : 'did not get it either'}`
209
+ );
210
+ escalationFields = { stealth: { engine, vendor_detected: vendorDetected } };
211
+ } catch (err) {
212
+ // A robots refusal inside the gate stamps markPreflightRefusal, and
213
+ // withAuth then bills the whole call zero: nothing was rendered.
214
+ warnings.push(`escalate: the stealth retry did not run — ${err.message}`);
215
+ }
216
+ }
217
+ }
218
+ // Reported only to a caller who asked to escalate.
219
+ if (escalating) escalationFields = { escalated: escalationRan, ...escalationFields };
220
+
221
+ // Globals the served HTML already carried are not repeated: the parsed
222
+ // copy under its own name is the same object, and some are megabytes.
223
+ const windowFound = [];
224
+ if (windowState) {
225
+ const parsedVariables = new Set(state.found.map((f) => f.variable));
226
+ for (const [name, value] of Object.entries(windowState)) {
227
+ if (parsedVariables.has(name)) {
228
+ delete windowState[name];
229
+ warnings.push(`window_state: ${name} is not repeated - the served HTML already carried it (see found)`);
230
+ continue;
231
+ }
232
+ windowFound.push({ name, bytes: Buffer.byteLength(JSON.stringify(value)) });
233
+ }
234
+ if (windowFound.length > 0) state.data.window_state = windowState;
235
+ }
236
+
237
+ // Escalation asked for and still walled, with nothing to show for it. An
238
+ // error that says whether the stage ran: when it did, withAuth's hint
239
+ // does not send the caller to the stealth browser a second time.
240
+ if (failed(verdict, status, state.found.length > 0 || windowFound.length > 0)) {
241
+ reportCost();
242
+ return {
243
+ content: [{
244
+ type: 'text',
245
+ text: JSON.stringify({
246
+ success: false,
247
+ url: finalUrl,
248
+ ...(typeof status === 'number' ? { status } : {}),
249
+ error: verdict.error,
250
+ ...(verdict.blocked ? { blocked: verdict.blocked } : {}),
251
+ ...escalationFields,
252
+ found: state.found,
253
+ warnings,
254
+ ...(escalationRan ? {} : { next_step: STAGE_DID_NOT_RUN_HINT })
255
+ }, null, 2)
256
+ }],
257
+ isError: true
258
+ };
259
+ }
260
+
261
+ if (state.found.length === 0 && windowFound.length === 0) {
57
262
  warnings.push(
58
- `path "${path}" was read as "${effectivePath}": "${state.found[0].name}" is the only payload on this page, so the path is resolved inside it.`
263
+ 'No embedded state found. The page may render entirely on the client, or ship its data in a format this tool does not read.'
59
264
  );
60
265
  }
61
- }
62
266
 
63
- const data = effectivePath ? selectJsonPath(state.data, effectivePath) : state.data;
64
- const bytes = Buffer.byteLength(JSON.stringify(data) ?? '');
267
+ // A path is naturally written against the payload ("props.pageProps"),
268
+ // not against this tool's envelope ("next_data.props.pageProps"). When
269
+ // the page carries exactly one payload and the path's root is not one of
270
+ // the envelope keys, read it inside that payload and say so (R21,
271
+ // 2026-09-09: four Next.js pages in a row failed on the bare path).
272
+ // `window_state` is an envelope key, so a path into it is left alone.
273
+ // `data_rows` is an index of next_f's rows, not a payload of its own.
274
+ const payloads = state.found.filter((f) => f.name !== 'data_rows');
275
+ let effectivePath = path;
276
+ if (path && payloads.length === 1) {
277
+ const root = path.split(/[.[]/)[0];
278
+ if (root && !(root in state.data)) {
279
+ effectivePath = `${payloads[0].name}.${path}`;
280
+ warnings.push(
281
+ `path "${path}" was read as "${effectivePath}": "${payloads[0].name}" is the only payload on this page, so the path is resolved inside it.`
282
+ );
283
+ }
284
+ }
285
+
286
+ const data = effectivePath ? selectJsonPath(state.data, effectivePath) : state.data;
287
+ const bytes = Buffer.byteLength(JSON.stringify(data) ?? '');
65
288
 
66
- if (!path && bytes > LARGE_RESULT_BYTES) {
67
- const largest = state.found.reduce((a, b) => (b.bytes > a.bytes ? b : a));
68
- warnings.push(
69
- `Result is ${bytes} bytes; "${largest.name}" alone is ${largest.bytes}. Re-run with path to scope it, e.g. path:"${largest.name}.${Object.keys(state.data[largest.name])[0]}".`
70
- );
71
- }
289
+ if (!path && !keys_only && find === undefined && bytes > LARGE_RESULT_BYTES) {
290
+ const largest = state.found.reduce((a, b) => (b.bytes > a.bytes ? b : a), state.found[0] ?? null);
291
+ if (largest) {
292
+ warnings.push(
293
+ `Result is ${bytes} bytes; "${largest.name}" alone is ${largest.bytes}. Re-run with path to scope it, e.g. path:"${largest.name}.${Object.keys(state.data[largest.name])[0]}", with keys_only:true to see its keys first, or with find:"<key>" to get the paths where a field of that name lives.`
294
+ );
295
+ }
296
+ }
72
297
 
73
- return {
74
- content: [{
75
- type: 'text',
76
- text: JSON.stringify({
77
- url: finalUrl,
78
- found: state.found,
79
- path: effectivePath || null,
80
- bytes,
81
- data,
82
- warnings
83
- }, null, 2)
84
- }]
85
- };
86
- } catch (error) {
87
- // A block or server error is not a path problem: the generic hint
88
- // ("call again without `path`") would send the caller back into the same
89
- // wall. Naming the next step here keeps withAuth from appending it.
90
- const blocked = /^HTTP (403|429|444|5\d\d)\b/.test(error.message);
91
- return {
92
- content: [{
93
- type: 'text',
94
- text: `Failed to extract embedded state: ${error.message}` + (blocked ? `\nNext step: ${BLOCKED_HINT}` : '')
95
- }],
96
- isError: true
97
- };
98
- }
298
+ // Match paths are relative to the selected data; prefixed with the path
299
+ // that selected it, each one can be passed straight back as `path`.
300
+ let search;
301
+ if (find !== undefined) {
302
+ search = findJsonPaths(data, find);
303
+ if (effectivePath) {
304
+ for (const match of search.matches) match.path = `${effectivePath}.${match.path}`;
305
+ }
306
+ }
307
+
308
+ reportCost();
309
+ return {
310
+ content: [{
311
+ type: 'text',
312
+ text: JSON.stringify({
313
+ url: finalUrl,
314
+ found: state.found,
315
+ path: effectivePath || null,
316
+ bytes,
317
+ ...escalationFields,
318
+ ...(windowFound.length > 0 ? { window_state: { note: WINDOW_STATE_NOTE, found: windowFound } } : {}),
319
+ ...(search
320
+ ? { matches: search.matches, matches_total: search.total, matches_truncated: search.truncated }
321
+ : (keys_only ? { keys: describeKeys(data, 2) } : { data })),
322
+ warnings
323
+ }, null, 2)
324
+ }]
325
+ };
326
+ } catch (error) {
327
+ reportCost();
328
+ // A block or server error is not a path problem: the generic hint
329
+ // ("call again without `path`") would send the caller back into the same
330
+ // wall. Naming the next step here keeps withAuth from appending it. A
331
+ // wall (here only one with a binary body, which fetchAndParse still
332
+ // throws on) points at escalate:true; a 5xx keeps the stealth_mode hint.
333
+ const hint = /^HTTP (403|429|444)\b/.test(error.message)
334
+ ? ESCALATE_HINT
335
+ : (/^HTTP 5\d\d\b/.test(error.message) ? BLOCKED_HINT : null);
336
+ return {
337
+ content: [{
338
+ type: 'text',
339
+ text: `Failed to extract embedded state: ${error.message}` + (hint ? `\nNext step: ${hint}` : '')
340
+ }],
341
+ isError: true
342
+ };
343
+ }
344
+ };
99
345
  }
346
+
347
+ /** The handler with no escalation stage wired (tests; server.js wires its own). */
348
+ export const extractEmbeddedStateHandler = createExtractEmbeddedStateHandler();
@@ -283,8 +283,9 @@ export class ProcessDocumentTool {
283
283
  */
284
284
  async processPDFDocument(result, source, sourceType, options, identity = {}) {
285
285
  // A remote PDF is a fetch of the target like any other; a local file is not.
286
+ let gate;
286
287
  if (sourceType === 'pdf_url') {
287
- const gate = await preflightFetch(source, {
288
+ gate = await preflightFetch(source, {
288
289
  respectRobots: identity.respect_robots,
289
290
  userAgent: identity.user_agent,
290
291
  tool: 'process_document'
@@ -302,7 +303,7 @@ export class ProcessDocumentTool {
302
303
  maxPages: options.maxPages,
303
304
  ...(options.pageRange ? { pageRange: options.pageRange } : {})
304
305
  }
305
- });
306
+ }, gate);
306
307
 
307
308
  if (!pdfResult.success) {
308
309
  throw new Error(pdfResult.error || 'PDF processing failed');
@@ -369,7 +370,7 @@ export class ProcessDocumentTool {
369
370
  enableImages: false,
370
371
  captureScreenshot: false
371
372
  }
372
- });
373
+ }, gate);
373
374
 
374
375
  if (!browserResult.success) {
375
376
  throw new Error(`Browser processing failed: ${browserResult.error}`);
@@ -381,6 +382,7 @@ export class ProcessDocumentTool {
381
382
  // Simple HTTP fetch
382
383
  const response = await safeFetch(source, {
383
384
  headers: { ...gate.headers },
385
+ onRedirect: gate.onRedirect,
384
386
  signal: AbortSignal.timeout(15000)
385
387
  });
386
388
 
@@ -380,7 +380,8 @@ export class GenerateLLMsTxtTool {
380
380
  });
381
381
  const response = await safeFetch(baseUrl, {
382
382
  signal: controller.signal,
383
- headers: { ...gate.headers }
383
+ headers: { ...gate.headers },
384
+ onRedirect: gate.onRedirect
384
385
  });
385
386
  if (!response.ok) return null;
386
387
  // Charset-aware read; .text() decodes UTF-8 only.
@@ -123,10 +123,13 @@ export class ScrapeTemplateTool {
123
123
  }
124
124
 
125
125
  // Robots gate + per-host politeness before any request to the target.
126
+ // A keyed connector's URL carries the key, and an override writes the URL
127
+ // to the audit log — so the gate is told what to strip from that row.
126
128
  const gate = await preflightFetch(fetchUrl, {
127
129
  respectRobots: respect_robots,
128
130
  userAgent: user_agent,
129
- tool: 'scrape_template'
131
+ tool: 'scrape_template',
132
+ redactCredential: apiKey
130
133
  });
131
134
 
132
135
  // Fetch the page
@@ -136,7 +139,8 @@ export class ScrapeTemplateTool {
136
139
  try {
137
140
  const response = await safeFetch(fetchUrl, {
138
141
  signal: controller.signal,
139
- headers: { ...gate.headers }
142
+ headers: { ...gate.headers },
143
+ onRedirect: gate.onRedirect
140
144
  });
141
145
  clearTimeout(timeoutId);
142
146
 
@@ -200,7 +204,11 @@ export class ScrapeTemplateTool {
200
204
  const timeoutId = setTimeout(() => controller.abort(), timeout);
201
205
  let response;
202
206
  try {
203
- response = await safeFetch(url, { signal: controller.signal, headers: { ...pageGate.headers } });
207
+ response = await safeFetch(url, {
208
+ signal: controller.signal,
209
+ headers: { ...pageGate.headers },
210
+ onRedirect: pageGate.onRedirect
211
+ });
204
212
  } finally {
205
213
  clearTimeout(timeoutId);
206
214
  }
@@ -72,6 +72,7 @@ export async function fetchContent(url, options = {}) {
72
72
  'Accept-Encoding': 'gzip, deflate',
73
73
  'Cache-Control': 'no-cache'
74
74
  },
75
+ onRedirect: gate.onRedirect,
75
76
  signal: AbortSignal.timeout(30000)
76
77
  });
77
78
 
@@ -102,7 +102,7 @@ export class HumanBehaviorSimulator {
102
102
  * Simulate human-like mouse movement to target coordinates
103
103
  */
104
104
  async simulateMouseMovement(page, fromX, fromY, toX, toY) {
105
- if (!this.config.mouseMovements.enabled) {
105
+ if (!this.config.mouseMovements.enabled || this.browserHumanizesCursor(page)) {
106
106
  await page.mouse.move(toX, toY);
107
107
  return;
108
108
  }
@@ -130,6 +130,25 @@ export class HumanBehaviorSimulator {
130
130
  this.stats.mouseMovements++;
131
131
  }
132
132
 
133
+ /**
134
+ * Whether the browser already animates every page.mouse.move itself.
135
+ *
136
+ * camoufox launched with `humanize` (StealthBrowserManager sets it from
137
+ * simulateHumanBehavior, on by default) draws its own curved path for each
138
+ * move, at ~1-2 s a call whatever the distance (measured 2026-09-29: 10 px
139
+ * took 950 ms, across the window 2.2 s). Our path is 11-101 moves, so the
140
+ * two stacked turned one click into 10-20 s and hit the action backstop; one
141
+ * move to the target is still a humanized path. camoufox is the only
142
+ * Firefox this server launches.
143
+ */
144
+ browserHumanizesCursor(page) {
145
+ try {
146
+ return page.context().browser()?.browserType().name() === 'firefox';
147
+ } catch {
148
+ return false;
149
+ }
150
+ }
151
+
133
152
  /**
134
153
  * Generate natural mouse movement path using Bezier curves
135
154
  */
@@ -312,7 +331,10 @@ export class HumanBehaviorSimulator {
312
331
  * Simulate human-like clicking with hover and delay
313
332
  */
314
333
  async simulateClick(page, selector, options = {}) {
315
- const element = await page.waitForSelector(selector);
334
+ // `timeout` bounds the wait for the element; left unset it is Playwright's
335
+ // 30 s default. It is not a mouse.click option, so it stays out of those.
336
+ const { timeout, ...clickOptions } = options;
337
+ const element = await page.waitForSelector(selector, { timeout });
316
338
  const boundingBox = await element.boundingBox();
317
339
 
318
340
  if (!boundingBox) {
@@ -339,7 +361,7 @@ export class HumanBehaviorSimulator {
339
361
  await this.delay(clickDelay);
340
362
 
341
363
  // Perform the click
342
- await page.mouse.click(clickX, clickY, options);
364
+ await page.mouse.click(clickX, clickY, clickOptions);
343
365
 
344
366
  this.stats.totalInteractions++;
345
367
  }
@@ -361,13 +383,13 @@ export class HumanBehaviorSimulator {
361
383
  } = options;
362
384
 
363
385
  if (target) {
364
- // Scroll to specific element
365
- await page.evaluate((sel) => {
366
- const element = document.querySelector(sel);
367
- if (element) {
368
- element.scrollIntoView({ behavior: 'smooth', block: 'center' });
369
- }
370
- }, target);
386
+ // Scroll to specific element. A locator, not document.querySelector: a
387
+ // snapshot ref resolves to `aria-ref=…`, which is not CSS, and its element
388
+ // may sit in a shadow root or an iframe that querySelector cannot reach.
389
+ const element = page.locator(target).first();
390
+ if (await element.count()) {
391
+ await element.evaluate((el) => el.scrollIntoView({ behavior: 'smooth', block: 'center' }));
392
+ }
371
393
  return;
372
394
  }
373
395
 
@@ -455,10 +477,9 @@ export class HumanBehaviorSimulator {
455
477
  let textLength = 0;
456
478
 
457
479
  if (selector) {
458
- textLength = await page.evaluate((sel) => {
459
- const element = document.querySelector(sel);
460
- return element ? element.textContent.length : 0;
461
- }, selector);
480
+ // A locator for the same reason as simulateScroll's target.
481
+ const element = page.locator(selector).first();
482
+ textLength = (await element.count()) ? await element.evaluate((el) => el.textContent.length) : 0;
462
483
  } else {
463
484
  textLength = await page.evaluate(() => document.body.textContent.length);
464
485
  }