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.
- package/README.md +1 -1
- package/package.json +3 -3
- package/server.js +63 -18
- package/src/cli/commands/stealth.js +2 -2
- package/src/core/ActionExecutor.js +140 -38
- 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 +25 -4
- 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 +19 -7
- package/src/core/processing/PDFProcessor.js +15 -6
- package/src/server/fallbackHints.js +7 -4
- package/src/server/inlineThreshold.js +81 -19
- package/src/skills/agent-skills/crawlforge-getting-started/references/credits.md +1 -1
- package/src/skills/agent-skills/crawlforge-structured-extraction/SKILL.md +25 -6
- package/src/tools/advanced/BrowserSessionTool.js +38 -13
- 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/sitemapParser.js +2 -1
- 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
|
|
115
|
-
//
|
|
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
|
|
375
|
-
// rethrowing, or the refused call leaks the
|
|
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
|
|
477
|
-
//
|
|
478
|
-
//
|
|
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 =
|
|
507
|
-
|
|
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
|
|
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
|
|
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) {
|
|
@@ -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;
|
|
@@ -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__,
|
|
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();
|