crawlforge-mcp-server 6.14.0 → 6.17.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/package.json +3 -3
- package/server.js +74 -22
- package/src/cli/commands/stealth.js +2 -2
- package/src/core/ActionExecutor.js +219 -39
- package/src/core/AuthManager.js +12 -0
- package/src/core/LLMsTxtAnalyzer.js +1 -0
- package/src/core/ResearchOrchestrator.js +10 -4
- package/src/core/StealthBrowserManager.js +117 -13
- package/src/core/browser/actionQueue.js +4 -1
- package/src/core/browser/windowState.js +88 -0
- package/src/core/crawlers/BFSCrawler.js +12 -3
- package/src/core/processing/BrowserProcessor.js +27 -8
- package/src/core/processing/PDFProcessor.js +15 -6
- package/src/server/fallbackHints.js +7 -4
- package/src/server/inlineThreshold.js +81 -19
- package/src/skills/agent-skills/crawlforge-getting-started/references/credits.md +1 -1
- package/src/skills/agent-skills/crawlforge-structured-extraction/SKILL.md +25 -6
- package/src/tools/advanced/BrowserSessionTool.js +38 -13
- package/src/tools/advanced/ScrapeWithActionsTool.js +40 -4
- package/src/tools/advanced/batchScrape/worker.js +3 -1
- package/src/tools/basic/_fetch.js +3 -1
- package/src/tools/crawl/_sessionContext.js +2 -1
- package/src/tools/crawl/mapSite.js +2 -1
- package/src/tools/extract/_fetchAndParse.js +1 -0
- package/src/tools/extract/extractContent.js +2 -1
- package/src/tools/extract/extractEmbeddedState.js +320 -71
- package/src/tools/extract/processDocument.js +5 -3
- package/src/tools/llmstxt/generateLLMsTxt.js +2 -1
- package/src/tools/templates/ScrapeTemplateTool.js +11 -3
- package/src/tools/tracking/trackChanges/differ.js +1 -0
- package/src/utils/complianceAudit.js +35 -5
- package/src/utils/impitRung.js +16 -5
- package/src/utils/resignedFetch.js +165 -0
- package/src/utils/robotsChecker.js +6 -1
- package/src/utils/robotsGate.js +124 -11
- package/src/utils/secretMask.js +1 -1
- package/src/utils/sitemapParser.js +2 -1
- package/src/utils/ssrfGuard.js +88 -11
|
@@ -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
|
}
|
|
@@ -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 = {
|
|
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
|
|
64
|
-
*
|
|
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({
|
package/src/utils/impitRung.js
CHANGED
|
@@ -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
|
|
88
|
-
* URL, so falling through to it
|
|
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
|
-
|
|
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;
|