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
|
@@ -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__,
|
|
5
|
-
*
|
|
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
|
|
18
|
-
//
|
|
19
|
-
//
|
|
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
|
-
*
|
|
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
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
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
|
-
|
|
64
|
-
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
path
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
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, {
|
|
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
|
}
|
|
@@ -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
|
-
|
|
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,
|
|
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
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
}
|
|
370
|
-
}
|
|
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
|
-
|
|
459
|
-
|
|
460
|
-
|
|
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
|
}
|