crawlforge-mcp-server 6.14.0 → 6.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/README.md +1 -1
  2. package/package.json +3 -3
  3. package/server.js +63 -18
  4. package/src/cli/commands/stealth.js +2 -2
  5. package/src/core/ActionExecutor.js +140 -38
  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 +25 -4
  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 +19 -7
  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/batchScrape/worker.js +3 -1
  21. package/src/tools/basic/_fetch.js +3 -1
  22. package/src/tools/crawl/_sessionContext.js +2 -1
  23. package/src/tools/crawl/mapSite.js +2 -1
  24. package/src/tools/extract/_fetchAndParse.js +1 -0
  25. package/src/tools/extract/extractContent.js +2 -1
  26. package/src/tools/extract/extractEmbeddedState.js +320 -71
  27. package/src/tools/extract/processDocument.js +5 -3
  28. package/src/tools/llmstxt/generateLLMsTxt.js +2 -1
  29. package/src/tools/templates/ScrapeTemplateTool.js +11 -3
  30. package/src/tools/tracking/trackChanges/differ.js +1 -0
  31. package/src/utils/complianceAudit.js +35 -5
  32. package/src/utils/impitRung.js +16 -5
  33. package/src/utils/resignedFetch.js +165 -0
  34. package/src/utils/robotsChecker.js +6 -1
  35. package/src/utils/robotsGate.js +124 -11
  36. package/src/utils/sitemapParser.js +2 -1
  37. package/src/utils/ssrfGuard.js +88 -11
@@ -111,8 +111,10 @@ const BrowserSessionSchema = z.object({
111
111
  timeout: z.number().min(10000).max(120000).default(30000),
112
112
 
113
113
  // Applies to the call it is sent on: on `open` to the first load, on `act` to
114
- // every navigate in that call. It is never remembered by the session, so an
115
- // override has to be repeated as deliberately as it was made.
114
+ // every navigate in that call, and on any call to wherever the page has moved
115
+ // since it was last checked (a click, a form, a script). It is never
116
+ // remembered by the session, so an override has to be repeated as
117
+ // deliberately as it was made. A URL that passed is not asked about again.
116
118
  respect_robots: z.boolean().optional(),
117
119
 
118
120
  // snapshot
@@ -358,6 +360,8 @@ export class BrowserSessionTool {
358
360
 
359
361
  let session;
360
362
  try {
363
+ // The load was gated; a script may have moved the page on since.
364
+ await this.actionExecutor.assertPageAllowed(page, browserOptions);
361
365
  session = this.store.create({
362
366
  ownerId,
363
367
  page,
@@ -371,8 +375,9 @@ export class BrowserSessionTool {
371
375
  maxPerOwner: ownerId.startsWith(REST_OWNER_PREFIX) ? REST_MAX_SESSIONS_PER_OWNER : undefined
372
376
  });
373
377
  } catch (error) {
374
- // A cap refusal arrives with a live page in hand. Give it back before
375
- // rethrowing, or the refused call leaks the context it just pinned.
378
+ // A cap refusal, or a page the gate refused, arrives with a live page in
379
+ // hand. Give it back before rethrowing, or the refused call leaks the
380
+ // context it just pinned.
376
381
  await releasePage();
377
382
  throw error;
378
383
  }
@@ -438,13 +443,29 @@ export class BrowserSessionTool {
438
443
  }
439
444
  }
440
445
 
446
+ /**
447
+ * Read from a session's page once the URL it stands on has passed the gate.
448
+ *
449
+ * A session's page outlives the call that loaded it, and between calls it
450
+ * can go anywhere: the last `act` clicked a link, or a script redirected.
451
+ * ActionExecutor.readGated holds it to the SSRF guard and the blocklist/
452
+ * robots gate on both sides of the read, with this call's `respect_robots`.
453
+ */
454
+ readPage(session, params, read) {
455
+ return this.actionExecutor.readGated(
456
+ session.page,
457
+ { respectRobots: params.respect_robots, tool: 'browser_session' },
458
+ read
459
+ );
460
+ }
461
+
441
462
  async snapshotSession(params, ownerId) {
442
463
  const session = this.requireSession(params, ownerId);
443
464
 
444
- const snapshot = await captureSnapshot(session.page, {
465
+ const snapshot = await this.readPage(session, params, () => captureSnapshot(session.page, {
445
466
  interactiveOnly: params.interactive_only,
446
467
  maxNodes: params.max_nodes
447
- });
468
+ }));
448
469
 
449
470
  this.store.touch(session, session.page.url());
450
471
  return { success: true, operation: 'snapshot', ...sessionInfo(session), snapshot };
@@ -473,9 +494,11 @@ export class BrowserSessionTool {
473
494
  }
474
495
 
475
496
  // Every `navigate` in here re-runs the SSRF guard and the blocklist/robots
476
- // gate inside executeNavigateAction — verified, and the reason the gate is
477
- // not repeated here. A long-lived session is a repeatable navigation
478
- // primitive, so that per-hop check is what stops it becoming an SSRF hop.
497
+ // gate inside executeNavigateAction, and every action is bracketed by the
498
+ // same check on wherever the page has moved (executeGatedAction) — the
499
+ // reason the gate is not repeated here. A long-lived session is a
500
+ // repeatable navigation primitive, so those checks are what stop it
501
+ // becoming an SSRF hop.
479
502
  const result = await this.actionExecutor.executeActionsOnPage(session.page, params.actions, {
480
503
  continueOnError: params.continue_on_error,
481
504
  timeout: params.timeout,
@@ -503,8 +526,10 @@ export class BrowserSessionTool {
503
526
 
504
527
  async readSession(params, ownerId) {
505
528
  const session = this.requireSession(params, ownerId);
506
- const url = session.page.url();
507
- const html = await session.page.content();
529
+ const { url, html } = await this.readPage(session, params, async () => ({
530
+ url: session.page.url(),
531
+ html: await session.page.content()
532
+ }));
508
533
 
509
534
  const options = {};
510
535
  if (params.formats.includes('markdown')) options.outputFormat = 'markdown';
@@ -564,12 +589,12 @@ export class BrowserSessionTool {
564
589
  async screenshotSession(params, ownerId) {
565
590
  const session = this.requireSession(params, ownerId);
566
591
 
567
- const shot = await this.actionExecutor.captureScreenshot(session.page, {
592
+ const shot = await this.readPage(session, params, () => this.actionExecutor.captureScreenshot(session.page, {
568
593
  fullPage: params.full_page,
569
594
  format: params.format,
570
595
  quality: params.quality,
571
596
  selector: params.selector
572
- });
597
+ }));
573
598
 
574
599
  this.store.touch(session, session.page.url());
575
600
  // The actionId is what lets the server publish the image as a
@@ -10,6 +10,7 @@ import { config as appConfig } from '../../../constants/config.js';
10
10
  import { ssrfGuard, isSsrfError } from '../../../utils/ssrfGuard.js';
11
11
  import { noteRetryAfter } from '../../../utils/hostRateLimiter.js';
12
12
  import { preflightFetch } from '../../../utils/robotsGate.js';
13
+ import { fetchResigned } from '../../../utils/resignedFetch.js';
13
14
  import { htmlToMarkdown } from '../../../utils/htmlToMarkdown.js';
14
15
  import { elementText } from '../../../utils/elementText.js';
15
16
  import { pageTitle } from '../../../utils/pageTitle.js';
@@ -31,9 +32,10 @@ export async function fetchUrl(url, options = {}) {
31
32
  const controller = new AbortController();
32
33
  const timeoutId = setTimeout(() => controller.abort(), timeout);
33
34
  try {
34
- const response = await fetch(url, {
35
+ const response = await fetchResigned(url, {
35
36
  signal: controller.signal,
36
37
  headers: { ...gate.headers, ...headers },
38
+ onRedirect: gate.onRedirect,
37
39
  ...guard
38
40
  });
39
41
  if (response.status === 429 || response.status === 503) {
@@ -8,6 +8,7 @@ import { config } from '../../constants/config.js';
8
8
  import { ssrfGuard, isSsrfError } from '../../utils/ssrfGuard.js';
9
9
  import { noteRetryAfter } from '../../utils/hostRateLimiter.js';
10
10
  import { preflightFetch } from '../../utils/robotsGate.js';
11
+ import { fetchResigned } from '../../utils/resignedFetch.js';
11
12
 
12
13
  /**
13
14
  * Fetch a URL with a configurable timeout and body-size cap.
@@ -51,12 +52,13 @@ export async function fetchWithTimeout(url, options = {}) {
51
52
  try {
52
53
  let response;
53
54
  try {
54
- response = await fetch(url, {
55
+ response = await fetchResigned(url, {
55
56
  signal: controller.signal,
56
57
  headers: {
57
58
  ...gate.headers,
58
59
  ...headers
59
60
  },
61
+ onRedirect: gate.onRedirect,
60
62
  ...guard
61
63
  });
62
64
  } catch (error) {
@@ -223,7 +223,8 @@ export class SessionContext {
223
223
  const fetchOpts = {
224
224
  method,
225
225
  headers: requestHeaders,
226
- redirect: 'follow'
226
+ redirect: 'follow',
227
+ onRedirect: gate.onRedirect
227
228
  };
228
229
 
229
230
  if (body) {
@@ -384,7 +384,8 @@ export class MapSiteTool {
384
384
  try {
385
385
  const response = await safeFetch(url, {
386
386
  signal: controller.signal,
387
- headers: gate.headers
387
+ headers: gate.headers,
388
+ onRedirect: gate.onRedirect
388
389
  });
389
390
  clearTimeout(timeoutId);
390
391
  return response;
@@ -147,6 +147,7 @@ export async function fetchAndParse(url, options = {}) {
147
147
  ...gate.headers,
148
148
  'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8'
149
149
  },
150
+ onRedirect: gate.onRedirect,
150
151
  signal: AbortSignal.timeout(timeoutMs)
151
152
  });
152
153
 
@@ -176,7 +176,7 @@ export class ExtractContentTool {
176
176
  enableImages: options.preserveImageInfo,
177
177
  captureScreenshot: false
178
178
  }
179
- });
179
+ }, gate);
180
180
 
181
181
  if (!browserResult.success) {
182
182
  throw new Error(`Browser processing failed: ${browserResult.error}`);
@@ -188,6 +188,7 @@ export class ExtractContentTool {
188
188
  // Simple HTTP fetch
189
189
  const response = await safeFetch(url, {
190
190
  headers: { ...gate.headers },
191
+ onRedirect: gate.onRedirect,
191
192
  signal: AbortSignal.timeout(15000)
192
193
  });
193
194
 
@@ -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();