crawlforge-mcp-server 6.14.0 → 6.17.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/README.md +1 -1
  2. package/package.json +3 -3
  3. package/server.js +74 -22
  4. package/src/cli/commands/stealth.js +2 -2
  5. package/src/core/ActionExecutor.js +219 -39
  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 +117 -13
  10. package/src/core/browser/actionQueue.js +4 -1
  11. package/src/core/browser/windowState.js +88 -0
  12. package/src/core/crawlers/BFSCrawler.js +12 -3
  13. package/src/core/processing/BrowserProcessor.js +27 -8
  14. package/src/core/processing/PDFProcessor.js +15 -6
  15. package/src/server/fallbackHints.js +7 -4
  16. package/src/server/inlineThreshold.js +81 -19
  17. package/src/skills/agent-skills/crawlforge-getting-started/references/credits.md +1 -1
  18. package/src/skills/agent-skills/crawlforge-structured-extraction/SKILL.md +25 -6
  19. package/src/tools/advanced/BrowserSessionTool.js +38 -13
  20. package/src/tools/advanced/ScrapeWithActionsTool.js +40 -4
  21. package/src/tools/advanced/batchScrape/worker.js +3 -1
  22. package/src/tools/basic/_fetch.js +3 -1
  23. package/src/tools/crawl/_sessionContext.js +2 -1
  24. package/src/tools/crawl/mapSite.js +2 -1
  25. package/src/tools/extract/_fetchAndParse.js +1 -0
  26. package/src/tools/extract/extractContent.js +2 -1
  27. package/src/tools/extract/extractEmbeddedState.js +320 -71
  28. package/src/tools/extract/processDocument.js +5 -3
  29. package/src/tools/llmstxt/generateLLMsTxt.js +2 -1
  30. package/src/tools/templates/ScrapeTemplateTool.js +11 -3
  31. package/src/tools/tracking/trackChanges/differ.js +1 -0
  32. package/src/utils/complianceAudit.js +35 -5
  33. package/src/utils/impitRung.js +16 -5
  34. package/src/utils/resignedFetch.js +165 -0
  35. package/src/utils/robotsChecker.js +6 -1
  36. package/src/utils/robotsGate.js +124 -11
  37. package/src/utils/secretMask.js +1 -1
  38. package/src/utils/sitemapParser.js +2 -1
  39. 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
 
@@ -13,7 +13,9 @@
13
13
  * best-effort: an audit sink that throws must never fail a customer's fetch.
14
14
  *
15
15
  * The API key is never stored. `apiKeyId` is a truncated SHA-256 of it — stable
16
- * enough to group a key's overrides, useless as a credential.
16
+ * enough to group a key's overrides, useless as a credential. A row's `url` is
17
+ * kept, because the row has to say what was fetched, but not a credential that
18
+ * rode along in it (see `auditUrl`).
17
19
  */
18
20
 
19
21
  import { createHash } from 'crypto';
@@ -35,6 +37,30 @@ export function apiKeyId(apiKey) {
35
37
  return createHash('sha256').update(apiKey).digest('hex').slice(0, 16);
36
38
  }
37
39
 
40
+ /**
41
+ * Query parameters whose value is a credential, by name: `token`,
42
+ * `access_token`, `api_key`, `key`, `sig`, `X-Amz-Signature`, `password`,
43
+ * `PHPSESSID`, `authorization`. `author` and `keyword` are not.
44
+ */
45
+ const CREDENTIAL_PARAM_RE = /token|secret|passw|signature|credential|sess(?:ion|id)|api[_-]?key|access[_-]?key|private[_-]?key|jwt|auth(?!or(?!i[sz]))|^(?:key|sig|sid|pwd|pass)$/i;
46
+
47
+ /**
48
+ * A URL as a row may keep it: `user:password@` removed, and the value of every
49
+ * query parameter named like a credential replaced. The path and every other
50
+ * parameter stay as they were sent. Matching is by name, so a credential under
51
+ * a name that says nothing (`?k=`, or one carried in the path) is not caught.
52
+ * @param {string} url
53
+ * @returns {string}
54
+ */
55
+ export function auditUrl(url) {
56
+ if (typeof url !== 'string') return url;
57
+ return url
58
+ .replace(/^([a-z][a-z0-9+.-]*:\/\/)[^/?#@]*@/i, '$1')
59
+ .replace(/([?&])([^=&#]+)=[^&#]*/g, (pair, separator, name) => (
60
+ CREDENTIAL_PARAM_RE.test(name) ? `${separator}${name}=[redacted]` : pair
61
+ ));
62
+ }
63
+
38
64
  async function defaultSink(row) {
39
65
  await mkdir(dirname(DEFAULT_LOG_PATH), { recursive: true });
40
66
  await appendFile(DEFAULT_LOG_PATH, `${JSON.stringify(row)}\n`, 'utf8');
@@ -46,7 +72,11 @@ async function defaultSink(row) {
46
72
  * @returns {{ event: string, timestamp: string }} the row as written
47
73
  */
48
74
  export function recordComplianceEvent(event = {}) {
49
- const row = { timestamp: new Date().toISOString(), ...event };
75
+ const row = {
76
+ timestamp: new Date().toISOString(),
77
+ ...event,
78
+ ...(event.url === undefined ? {} : { url: auditUrl(event.url) })
79
+ };
50
80
  ring.push(row);
51
81
  if (ring.length > RING_SIZE) ring.shift();
52
82
 
@@ -60,9 +90,9 @@ export function recordComplianceEvent(event = {}) {
60
90
  /**
61
91
  * Record that a request is about to go out under a browser identity (a stealth
62
92
  * render). Neither the raw key nor the raw owner token is stored: both are
63
- * reduced to apiKeyId digests. `ownerId` names the website customer behind an
64
- * internal-proxy request, where `apiKeyId` is always the service's own key.
65
- * Never throws.
93
+ * reduced to apiKeyId digests, and the URL is kept as `auditUrl` leaves it.
94
+ * `ownerId` names the website customer behind an internal-proxy request, where
95
+ * `apiKeyId` is always the service's own key. Never throws.
66
96
  */
67
97
  export function recordStealthEscalation({ url, tool, engine, apiKey, ownerToken } = {}) {
68
98
  return recordComplianceEvent({
@@ -22,7 +22,8 @@
22
22
  *
23
23
  * impit resolves DNS itself, outside the undici dispatcher ssrfGuard installs,
24
24
  * so redirects are followed here, one hop at a time, and every hop is checked
25
- * with DNS resolution — the same check safeGoto applies to a navigation.
25
+ * with DNS resolution — the same check safeGoto applies to a navigation — and
26
+ * put to the caller's `onRedirect` gate before it is requested.
26
27
  */
27
28
 
28
29
  import { load } from 'cheerio';
@@ -84,16 +85,19 @@ export async function loadImpit() {
84
85
  * not (a wall, an error page, an empty shell) or the request failed. A null
85
86
  * means "run the browser"; this function never throws for a failed fetch.
86
87
  *
87
- * An SSRF refusal on any hop DOES throw: the browser would be refused the same
88
- * URL, so falling through to it would only fail slower.
88
+ * An SSRF refusal on any hop DOES throw, and so does a hop the caller's gate
89
+ * refuses: the browser would be refused the same URL, so falling through to it
90
+ * would only fail slower.
89
91
  *
90
92
  * @param {string} url
91
93
  * @param {object} [options]
92
94
  * @param {Function} [options.Impit] the client class (tests inject a fake)
93
95
  * @param {number} [options.timeoutMs]
96
+ * @param {(to: string) => Promise<void>} [options.onRedirect] awaited with each
97
+ * redirect target before it is requested (robotsGate.js `redirectGate`)
94
98
  * @returns {Promise<{ html: string, text: string, title: string, url: string, status: number, engine: string } | null>}
95
99
  */
96
- export async function impitFetchPage(url, { Impit, timeoutMs = IMPIT_TIMEOUT_MS } = {}) {
100
+ export async function impitFetchPage(url, { Impit, timeoutMs = IMPIT_TIMEOUT_MS, onRedirect } = {}) {
97
101
  const ImpitClass = Impit ?? await loadImpit();
98
102
  if (!ImpitClass) return null;
99
103
 
@@ -108,7 +112,7 @@ export async function impitFetchPage(url, { Impit, timeoutMs = IMPIT_TIMEOUT_MS
108
112
  ...(proxyUrl ? { proxyUrl } : {})
109
113
  });
110
114
 
111
- const deadline = Date.now() + timeoutMs;
115
+ let deadline = Date.now() + timeoutMs;
112
116
  let current = url;
113
117
  let response;
114
118
  for (let hop = 0; ; hop++) {
@@ -129,6 +133,13 @@ export async function impitFetchPage(url, { Impit, timeoutMs = IMPIT_TIMEOUT_MS
129
133
  return null;
130
134
  }
131
135
  if (!/^https?:\/\//i.test(current)) return null;
136
+ if (onRedirect) {
137
+ // The gate may wait out the next host's Crawl-delay. That is our
138
+ // waiting, not the site being slow, so the deadline moves with it.
139
+ const gateStartedAt = Date.now();
140
+ await onRedirect(current);
141
+ deadline += Date.now() - gateStartedAt;
142
+ }
132
143
  }
133
144
 
134
145
  let html;