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
|
@@ -17,11 +17,12 @@ import path from 'path';
|
|
|
17
17
|
import { createRequire } from 'module';
|
|
18
18
|
import HumanBehaviorSimulator from '../utils/HumanBehaviorSimulator.js';
|
|
19
19
|
import { BrowserContextPool } from './BrowserContextPool.js';
|
|
20
|
-
import { safeGoto } from '../utils/ssrfGuard.js';
|
|
20
|
+
import { safeGoto, assertNavigationAllowed } from '../utils/ssrfGuard.js';
|
|
21
21
|
import { looksLikeInterstitial, detectChallengePage } from '../utils/challengeDetection.js';
|
|
22
22
|
import { guardFirefoxPageErrors } from '../utils/firefoxPageErrorGuard.js';
|
|
23
23
|
import { serverStealthProxies } from '../constants/config.js';
|
|
24
24
|
import { ClearanceJar, sharedClearanceJar } from './ClearanceJar.js';
|
|
25
|
+
import { readWindowState } from './browser/windowState.js';
|
|
25
26
|
|
|
26
27
|
// Grace given to a document that rendered no title and no text (see _waitOutEmptyDocument).
|
|
27
28
|
export const EMPTY_DOCUMENT_GRACE_MS = 8000;
|
|
@@ -2844,7 +2845,16 @@ export class StealthBrowserManager {
|
|
|
2844
2845
|
return this._clickTurnstile(page);
|
|
2845
2846
|
}
|
|
2846
2847
|
|
|
2847
|
-
|
|
2848
|
+
/**
|
|
2849
|
+
* @param {object} options
|
|
2850
|
+
* @param {boolean} [options.readWindowState] also read the framework globals
|
|
2851
|
+
* off `window` once the page has settled (extract_embedded_state's
|
|
2852
|
+
* escalation, src/core/browser/windowState.js); returned as `windowState`
|
|
2853
|
+
* @param {(to: string) => Promise<void>} [options.onRedirect] the caller's
|
|
2854
|
+
* gate for wherever the page is redirected to (robotsGate.js
|
|
2855
|
+
* `redirectGate`); a refusal throws and nothing of the page is returned
|
|
2856
|
+
*/
|
|
2857
|
+
async scrapeWithStealth({ url, engine, wait_for = 0, screenshot = false, stealthConfig = {}, readWindowState: wantWindowState = false, onRedirect } = {}) {
|
|
2848
2858
|
if (!url) throw new Error('scrapeWithStealth requires a url');
|
|
2849
2859
|
|
|
2850
2860
|
const { contextId, engineFallbackWarning = null } = await this.createStealthContext({ ...stealthConfig, engine });
|
|
@@ -2870,7 +2880,8 @@ export class StealthBrowserManager {
|
|
|
2870
2880
|
});
|
|
2871
2881
|
// SSRF guard at the navigation boundary: the stealth engine resolves DNS
|
|
2872
2882
|
// itself, so a URL/host-only check would miss rebinding to private/metadata IPs.
|
|
2873
|
-
const response = await safeGoto(page, url, { waitUntil: 'domcontentloaded' });
|
|
2883
|
+
const response = await safeGoto(page, url, { waitUntil: 'domcontentloaded', onRedirect });
|
|
2884
|
+
const landedUrl = page.url();
|
|
2874
2885
|
if (response && status === null) status = response.status();
|
|
2875
2886
|
if (wait_for > 0) await page.waitForTimeout(wait_for);
|
|
2876
2887
|
// Cloudflare's and Vercel's JavaScript challenges solve themselves in a
|
|
@@ -2888,6 +2899,10 @@ export class StealthBrowserManager {
|
|
|
2888
2899
|
// a successful scrape.
|
|
2889
2900
|
const renderMs = await this._settleRender(page);
|
|
2890
2901
|
const gracedMs = emptyGraceMs + renderMs;
|
|
2902
|
+
// The waits above are time in which a challenge or a client-side redirect
|
|
2903
|
+
// can move the page on. The document read below is the one that has to
|
|
2904
|
+
// pass, so wherever the page stands now is checked like a redirect hop.
|
|
2905
|
+
await assertNavigationAllowed(page, landedUrl, null, onRedirect);
|
|
2891
2906
|
|
|
2892
2907
|
// A failure to read the document is a failure. With every read wrapped
|
|
2893
2908
|
// in .catch(() => ''), a renderer that crashed or a page closed during
|
|
@@ -2913,6 +2928,12 @@ export class StealthBrowserManager {
|
|
|
2913
2928
|
const shot = screenshot
|
|
2914
2929
|
? await page.screenshot({ encoding: 'base64', fullPage: false }).catch(() => null)
|
|
2915
2930
|
: null;
|
|
2931
|
+
let windowState;
|
|
2932
|
+
if (wantWindowState) {
|
|
2933
|
+
const read = await readWindowState(page);
|
|
2934
|
+
windowState = read.state;
|
|
2935
|
+
warnings.push(...read.warnings);
|
|
2936
|
+
}
|
|
2916
2937
|
|
|
2917
2938
|
// Still a wall after everything above: whatever clearance this context
|
|
2918
2939
|
// carried for the site did not work, so closeContext drops it instead of
|
|
@@ -2921,7 +2942,7 @@ export class StealthBrowserManager {
|
|
|
2921
2942
|
this._markBlocked(contextId, [url, page.url()]);
|
|
2922
2943
|
}
|
|
2923
2944
|
|
|
2924
|
-
return { success: true, url, title, text, html, screenshot: shot, status, gracedMs, engine: engineUsed, warnings };
|
|
2945
|
+
return { success: true, url, title, text, html, screenshot: shot, status, gracedMs, engine: engineUsed, warnings, ...(windowState ? { windowState } : {}) };
|
|
2925
2946
|
} finally {
|
|
2926
2947
|
await this.closeContext(contextId).catch(() => {});
|
|
2927
2948
|
}
|
|
@@ -57,7 +57,10 @@ export function createActionQueue({
|
|
|
57
57
|
'CRAWLFORGE_MAX_ACTION_SESSIONS / CRAWLFORGE_ACTION_QUEUE_TIMEOUT_MS if the machine has room.'
|
|
58
58
|
));
|
|
59
59
|
}, timeoutMs);
|
|
60
|
-
|
|
60
|
+
// Not unref()'d: a caller is awaiting this wait, so it must keep the event
|
|
61
|
+
// loop alive. Unref'd, Node 22 could exit mid-wait with the promise still
|
|
62
|
+
// pending (it did, under node:test in CI). The timer never outlives the
|
|
63
|
+
// wait — it is cleared when the job starts.
|
|
61
64
|
|
|
62
65
|
return queue.add(() => {
|
|
63
66
|
clearTimeout(timer);
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* windowState — read a page's framework state straight off `window`, after
|
|
3
|
+
* its JavaScript ran (extract_embedded_state's escalated path, plan Phase 3.2).
|
|
4
|
+
*
|
|
5
|
+
* The served HTML is not always where the state ends up: Nuxt 2 ships
|
|
6
|
+
* __NUXT__ as an IIFE the HTML parser cannot evaluate, and YouTube's
|
|
7
|
+
* ytInitialData is assigned by a script. Inside the stealth render those are
|
|
8
|
+
* plain globals, so they are read as the page itself sees them.
|
|
9
|
+
*
|
|
10
|
+
* Engines: on Chromium page.evaluate runs in the page's main world, where the
|
|
11
|
+
* globals live. On camoufox it runs in an isolated world that cannot see them
|
|
12
|
+
* (window.__NEXT_DATA__ is undefined there), but Firefox's Xray waiver,
|
|
13
|
+
* `window.wrappedJSObject`, reaches the page's own window from that world
|
|
14
|
+
* without evaluating anything in the page's — measured against camoufox
|
|
15
|
+
* 0.1.19, 2026-09-29. So the read works on both engines and the page never
|
|
16
|
+
* sees a script of ours run in its world.
|
|
17
|
+
*
|
|
18
|
+
* Each value is serialised INSIDE the page with its own try/catch: a cyclic or
|
|
19
|
+
* otherwise unserialisable global is skipped with a warning instead of
|
|
20
|
+
* failing the read of the others.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** The globals read, in this order. Only the ones present come back. */
|
|
24
|
+
export const WINDOW_STATE_GLOBALS = Object.freeze([
|
|
25
|
+
'__NEXT_DATA__',
|
|
26
|
+
'__NUXT__',
|
|
27
|
+
'__remixContext',
|
|
28
|
+
'ytInitialData',
|
|
29
|
+
'ytInitialPlayerResponse',
|
|
30
|
+
'__INITIAL_STATE__',
|
|
31
|
+
'__PRELOADED_STATE__',
|
|
32
|
+
'__APOLLO_STATE__',
|
|
33
|
+
'__TGT_DATA__',
|
|
34
|
+
'__PWS_DATA__'
|
|
35
|
+
]);
|
|
36
|
+
|
|
37
|
+
/** Said wherever these values are reported: they are not what the server sent. */
|
|
38
|
+
export const WINDOW_STATE_NOTE = 'read from window after JavaScript ran, not from the served HTML';
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Runs in the page. `{ json }` for a global that serialised, `{ error }` for
|
|
42
|
+
* one that did not; absent (null/undefined) globals are left out.
|
|
43
|
+
* @param {string[]} names
|
|
44
|
+
*/
|
|
45
|
+
export function serialiseWindowGlobals(names) {
|
|
46
|
+
const target = window.wrappedJSObject || window;
|
|
47
|
+
const out = {};
|
|
48
|
+
for (const name of names) {
|
|
49
|
+
try {
|
|
50
|
+
const value = target[name];
|
|
51
|
+
if (value === undefined || value === null) continue;
|
|
52
|
+
const json = JSON.stringify(value);
|
|
53
|
+
out[name] = typeof json === 'string' ? { json } : { error: 'it has no JSON form' };
|
|
54
|
+
} catch (error) {
|
|
55
|
+
out[name] = { error: String((error && error.message) || error) };
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* @param {{ evaluate: Function }} page a Playwright page
|
|
63
|
+
* @returns {Promise<{ state: Record<string, unknown>, warnings: string[] }>}
|
|
64
|
+
*/
|
|
65
|
+
export async function readWindowState(page) {
|
|
66
|
+
const warnings = [];
|
|
67
|
+
let raw;
|
|
68
|
+
try {
|
|
69
|
+
raw = await page.evaluate(serialiseWindowGlobals, [...WINDOW_STATE_GLOBALS]);
|
|
70
|
+
} catch (error) {
|
|
71
|
+
warnings.push(`window_state could not be read: ${String(error.message).split('\n')[0]}`);
|
|
72
|
+
return { state: {}, warnings };
|
|
73
|
+
}
|
|
74
|
+
const state = {};
|
|
75
|
+
for (const [name, entry] of Object.entries(raw || {})) {
|
|
76
|
+
if (entry && typeof entry.json === 'string') {
|
|
77
|
+
try {
|
|
78
|
+
state[name] = JSON.parse(entry.json);
|
|
79
|
+
continue;
|
|
80
|
+
} catch (error) {
|
|
81
|
+
warnings.push(`window_state: ${name} skipped - its JSON did not parse (${error.message})`);
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
warnings.push(`window_state: ${name} skipped - not serialisable (${entry?.error ?? 'unknown reason'})`);
|
|
86
|
+
}
|
|
87
|
+
return { state, warnings };
|
|
88
|
+
}
|
|
@@ -8,9 +8,10 @@ import { LinkAnalyzer } from '../analysis/LinkAnalyzer.js';
|
|
|
8
8
|
import { normalizeUrl, extractLinks, isValidUrl } from '../../utils/urlNormalizer.js';
|
|
9
9
|
import { Logger } from '../../utils/Logger.js';
|
|
10
10
|
import { safeFetch } from '../../utils/ssrfGuard.js';
|
|
11
|
-
import { robotsPreflight, RobotsDisallowedError } from '../../utils/robotsGate.js';
|
|
11
|
+
import { robotsPreflight, redirectGate, RobotsDisallowedError } from '../../utils/robotsGate.js';
|
|
12
12
|
import { throttleHost } from '../../utils/hostRateLimiter.js';
|
|
13
13
|
import { CRAWLFORGE_USER_AGENT, identityHeaders } from '../../utils/fetchIdentity.js';
|
|
14
|
+
import { signRequestHeaders } from '../../utils/webBotAuth.js';
|
|
14
15
|
import { pageTitle } from '../../utils/pageTitle.js';
|
|
15
16
|
import { extractMainContent, isThinMainContent } from '../../tools/scrape/_mainContent.js';
|
|
16
17
|
|
|
@@ -337,7 +338,10 @@ export class BFSCrawler {
|
|
|
337
338
|
'Accept-Language': 'en-US,en;q=0.5',
|
|
338
339
|
'Accept-Encoding': 'gzip, deflate',
|
|
339
340
|
'Connection': 'keep-alive',
|
|
340
|
-
'Upgrade-Insecure-Requests': '1'
|
|
341
|
+
'Upgrade-Insecure-Requests': '1',
|
|
342
|
+
// Web Bot Auth, as on every other fetching tool. crawl_deep pages went
|
|
343
|
+
// out with the identity alone: a claim with no proof.
|
|
344
|
+
...signRequestHeaders(url)
|
|
341
345
|
};
|
|
342
346
|
|
|
343
347
|
let headers = { ...defaultHeaders, ...domainRules.customHeaders };
|
|
@@ -357,7 +361,12 @@ export class BFSCrawler {
|
|
|
357
361
|
|
|
358
362
|
const response = await safeFetch(url, {
|
|
359
363
|
signal: controller.signal,
|
|
360
|
-
headers
|
|
364
|
+
headers,
|
|
365
|
+
onRedirect: redirectGate(url, {
|
|
366
|
+
respectRobots: this.respectRobots,
|
|
367
|
+
userAgent: this.userAgent,
|
|
368
|
+
tool: 'crawl_deep'
|
|
369
|
+
})
|
|
361
370
|
});
|
|
362
371
|
|
|
363
372
|
// Capture any cookies the server sets during the crawl
|
|
@@ -9,7 +9,7 @@ import { z } from 'zod';
|
|
|
9
9
|
import StealthBrowserManager from '../StealthBrowserManager.js';
|
|
10
10
|
import HumanBehaviorSimulator from '../../utils/HumanBehaviorSimulator.js';
|
|
11
11
|
import LocalizationManager from '../LocalizationManager.js';
|
|
12
|
-
import { safeGoto } from '../../utils/ssrfGuard.js';
|
|
12
|
+
import { safeGoto, assertNavigationAllowed } from '../../utils/ssrfGuard.js';
|
|
13
13
|
|
|
14
14
|
const BrowserProcessorSchema = z.object({
|
|
15
15
|
url: z.string().url(),
|
|
@@ -156,9 +156,12 @@ export class BrowserProcessor {
|
|
|
156
156
|
* @param {Object} params - Processing parameters
|
|
157
157
|
* @param {string} params.url - URL to process
|
|
158
158
|
* @param {Object} params.options - Browser options
|
|
159
|
+
* @param {{ onRedirect?: (to: string) => Promise<void> }} [gate] - what the
|
|
160
|
+
* calling tool's `preflightFetch` returned, so a redirect is gated the way
|
|
161
|
+
* the URL was. A second argument because the schema strips it from `params`.
|
|
159
162
|
* @returns {Promise<Object>} - Processing result with rendered content
|
|
160
163
|
*/
|
|
161
|
-
async processURL(params) {
|
|
164
|
+
async processURL(params, gate) {
|
|
162
165
|
const startTime = Date.now();
|
|
163
166
|
|
|
164
167
|
try {
|
|
@@ -178,7 +181,7 @@ export class BrowserProcessor {
|
|
|
178
181
|
|
|
179
182
|
try {
|
|
180
183
|
// Navigate and wait for content
|
|
181
|
-
const navigationResult = await this.navigateAndWait(page, url, processingOptions);
|
|
184
|
+
const navigationResult = await this.navigateAndWait(page, url, processingOptions, gate?.onRedirect);
|
|
182
185
|
|
|
183
186
|
// Extract content and metadata
|
|
184
187
|
const contentResult = await this.extractContent(page, processingOptions);
|
|
@@ -635,18 +638,23 @@ export class BrowserProcessor {
|
|
|
635
638
|
* @param {Page} page - Playwright page
|
|
636
639
|
* @param {string} url - URL to navigate to
|
|
637
640
|
* @param {Object} options - Navigation options
|
|
641
|
+
* @param {(to: string) => Promise<void>} [onRedirect] - the calling tool's
|
|
642
|
+
* redirect gate (robotsGate.js `redirectGate`)
|
|
638
643
|
* @returns {Promise<Object>} - Navigation result
|
|
639
644
|
*/
|
|
640
|
-
async navigateAndWait(page, url, options) {
|
|
645
|
+
async navigateAndWait(page, url, options, onRedirect) {
|
|
641
646
|
const startTime = Date.now();
|
|
642
647
|
|
|
643
648
|
// Navigate to URL. safeGoto adds the SSRF/DNS-rebinding guard on top of the
|
|
644
|
-
// robots/blocklist preflight the calling tool already ran, and re-checks
|
|
645
|
-
//
|
|
649
|
+
// robots/blocklist preflight the calling tool already ran, and re-checks
|
|
650
|
+
// where the navigation went, so a redirect can't carry us into a
|
|
651
|
+
// private/metadata range or past the gate the URL itself went through.
|
|
646
652
|
await safeGoto(page, url, {
|
|
647
653
|
waitUntil: 'domcontentloaded',
|
|
648
|
-
timeout: 30000
|
|
654
|
+
timeout: 30000,
|
|
655
|
+
onRedirect
|
|
649
656
|
});
|
|
657
|
+
const landedUrl = page.url();
|
|
650
658
|
|
|
651
659
|
// Wait for specific selector if provided
|
|
652
660
|
if (options.waitForSelector) {
|
|
@@ -687,6 +695,10 @@ export class BrowserProcessor {
|
|
|
687
695
|
}
|
|
688
696
|
}
|
|
689
697
|
|
|
698
|
+
// The waits above are time in which a client-side redirect can move the
|
|
699
|
+
// page on; the document about to be read is the one that has to pass.
|
|
700
|
+
await assertNavigationAllowed(page, landedUrl, null, onRedirect);
|
|
701
|
+
|
|
690
702
|
return {
|
|
691
703
|
navigationTime: Date.now() - startTime
|
|
692
704
|
};
|
|
@@ -10,6 +10,7 @@ import path from 'path';
|
|
|
10
10
|
import { safeFetch } from '../../utils/ssrfGuard.js';
|
|
11
11
|
import { config } from '../../constants/config.js';
|
|
12
12
|
import { identityHeaders } from '../../utils/fetchIdentity.js';
|
|
13
|
+
import { signRequestHeaders } from '../../utils/webBotAuth.js';
|
|
13
14
|
|
|
14
15
|
const PDFProcessorSchema = z.object({
|
|
15
16
|
// A Buffer is the 'buffer' source: process_document hands over a body it
|
|
@@ -85,9 +86,11 @@ export class PDFProcessor {
|
|
|
85
86
|
* @param {string} params.source - PDF source (URL, file path, or buffer)
|
|
86
87
|
* @param {string} params.sourceType - Type of source ('url', 'file', 'buffer')
|
|
87
88
|
* @param {Object} params.options - Processing options
|
|
89
|
+
* @param {{ headers: Record<string,string>, onRedirect: Function }} [gate] -
|
|
90
|
+
* what `preflightFetch` returned for a URL source
|
|
88
91
|
* @returns {Promise<Object>} - Processing result with text and metadata
|
|
89
92
|
*/
|
|
90
|
-
async processPDF(params) {
|
|
93
|
+
async processPDF(params, gate) {
|
|
91
94
|
const startTime = Date.now();
|
|
92
95
|
|
|
93
96
|
try {
|
|
@@ -106,7 +109,7 @@ export class PDFProcessor {
|
|
|
106
109
|
// Get PDF buffer based on source type
|
|
107
110
|
let pdfBuffer;
|
|
108
111
|
try {
|
|
109
|
-
pdfBuffer = await this.getPDFBuffer(source, sourceType);
|
|
112
|
+
pdfBuffer = await this.getPDFBuffer(source, sourceType, gate);
|
|
110
113
|
} catch (error) {
|
|
111
114
|
result.error = `Failed to load PDF: ${error.message}`;
|
|
112
115
|
result.processingTime = Date.now() - startTime;
|
|
@@ -214,12 +217,13 @@ export class PDFProcessor {
|
|
|
214
217
|
* Get PDF buffer from various sources
|
|
215
218
|
* @param {string} source - PDF source
|
|
216
219
|
* @param {string} sourceType - Source type
|
|
220
|
+
* @param {Object} [gate] - see processPDF
|
|
217
221
|
* @returns {Promise<Buffer>} - PDF buffer
|
|
218
222
|
*/
|
|
219
|
-
async getPDFBuffer(source, sourceType) {
|
|
223
|
+
async getPDFBuffer(source, sourceType, gate) {
|
|
220
224
|
switch (sourceType) {
|
|
221
225
|
case 'url':
|
|
222
|
-
return await this.downloadPDFFromURL(source);
|
|
226
|
+
return await this.downloadPDFFromURL(source, gate);
|
|
223
227
|
case 'file':
|
|
224
228
|
return await this.readPDFFromFile(source);
|
|
225
229
|
case 'buffer':
|
|
@@ -232,15 +236,20 @@ export class PDFProcessor {
|
|
|
232
236
|
/**
|
|
233
237
|
* Download PDF from URL
|
|
234
238
|
* @param {string} url - PDF URL
|
|
239
|
+
* @param {Object} [gate] - see processPDF
|
|
235
240
|
* @returns {Promise<Buffer>} - PDF buffer
|
|
236
241
|
*/
|
|
237
|
-
async downloadPDFFromURL(url) {
|
|
242
|
+
async downloadPDFFromURL(url, gate) {
|
|
238
243
|
try {
|
|
239
244
|
// `timeout` is not a fetch init option — undici/Node fetch silently
|
|
240
245
|
// ignores unknown properties, so only `signal` actually enforces a
|
|
241
246
|
// deadline here.
|
|
247
|
+
// The gate's headers are the identity the URL was cleared under plus the
|
|
248
|
+
// Web Bot Auth signature; this download used to discard them and go out
|
|
249
|
+
// unsigned. With no gate, sign as ourselves.
|
|
242
250
|
const response = await safeFetch(url, {
|
|
243
|
-
headers: identityHeaders(),
|
|
251
|
+
headers: gate?.headers ?? { ...identityHeaders(), ...signRequestHeaders(url) },
|
|
252
|
+
onRedirect: gate?.onRedirect,
|
|
244
253
|
signal: AbortSignal.timeout(30000)
|
|
245
254
|
});
|
|
246
255
|
|
|
@@ -44,8 +44,8 @@ export const FALLBACK_HINTS = Object.freeze({
|
|
|
44
44
|
});
|
|
45
45
|
|
|
46
46
|
/**
|
|
47
|
-
* The second-stage hint (Phase 3, 3.4). A `scrape`
|
|
48
|
-
* `escalated: true` has ALREADY run the stealth browser on this URL, so the
|
|
47
|
+
* The second-stage hint (Phase 3, 3.4). A `scrape` or `extract_embedded_state`
|
|
48
|
+
* that came back `escalated: true` has ALREADY run the stealth browser on this URL, so the
|
|
49
49
|
* ordinary hint would send the model back to a tool that has just failed.
|
|
50
50
|
* What is left is a region-specific block, which `localization` can change,
|
|
51
51
|
* or a block that is final: an Akamai-style TLS-level wall is beaten with
|
|
@@ -56,9 +56,12 @@ export const SCRAPE_ESCALATED_HINT =
|
|
|
56
56
|
'If the block is regional, set a country with localization and try once more; otherwise the block is final - ' +
|
|
57
57
|
'a TLS-level wall needs residential proxies, which CrawlForge does not offer. Get the content from another source.';
|
|
58
58
|
|
|
59
|
-
/** The
|
|
59
|
+
/** The tools whose escalate:true runs the stealth stage itself (Phase 3; extract_embedded_state since plan Phase 3.2). */
|
|
60
|
+
const ESCALATING_TOOLS = new Set(['scrape', 'extract_embedded_state']);
|
|
61
|
+
|
|
62
|
+
/** The hint for this result: the tool's, unless the call already escalated. */
|
|
60
63
|
function hintFor(toolName, parsed) {
|
|
61
|
-
if (toolName
|
|
64
|
+
if (ESCALATING_TOOLS.has(toolName) && parsed?.escalated === true) return SCRAPE_ESCALATED_HINT;
|
|
62
65
|
return FALLBACK_HINTS[toolName];
|
|
63
66
|
}
|
|
64
67
|
|
|
@@ -20,9 +20,9 @@ export const MAX_INLINE_CHARS_PARAM = {
|
|
|
20
20
|
/**
|
|
21
21
|
* Per-tool rule. `textPaths` are dotted paths tried in order; the first one
|
|
22
22
|
* holding a string becomes the text view the preview and read_result work
|
|
23
|
-
* on. An empty list means the pretty-printed JSON is the view. `
|
|
24
|
-
*
|
|
25
|
-
*
|
|
23
|
+
* on. An empty list means the pretty-printed JSON is the view. `when` gates
|
|
24
|
+
* on params. extract_embedded_state has a shape of its own (shapeEmbeddedState
|
|
25
|
+
* below).
|
|
26
26
|
*/
|
|
27
27
|
/** The browser_session operations that hand back content worth shaping. */
|
|
28
28
|
const BROWSER_SESSION_CONTENT_OPERATIONS = new Set(['snapshot', 'act', 'read']);
|
|
@@ -52,7 +52,10 @@ export const INLINE_THRESHOLD_TOOLS = Object.freeze({
|
|
|
52
52
|
},
|
|
53
53
|
process_document: { textPaths: ['content.text'], truncate: true },
|
|
54
54
|
deep_research: { textPaths: [], truncate: true },
|
|
55
|
-
|
|
55
|
+
// Truncated since plan Phase 3.1: returned whole, producthunt.com and
|
|
56
|
+
// zappos.com overflowed the client. Shaped by shapeEmbeddedState, which never
|
|
57
|
+
// cuts inside a JSON value.
|
|
58
|
+
extract_embedded_state: { textPaths: [], truncate: true }
|
|
56
59
|
});
|
|
57
60
|
|
|
58
61
|
/** param -> env (an int of at least 1,000) -> default. */
|
|
@@ -147,21 +150,8 @@ export function applyInlineThreshold(toolName, resultObject, params, { store, en
|
|
|
147
150
|
const viewDesc = view === 'text' ? `the ${view_path} text` : 'the pretty-printed JSON';
|
|
148
151
|
const readWith = 'read it with read_result (1 credit) - operation "search" (query), "slice" (offset, length), "lines" or "json_path" (path) - and do not fetch the page again';
|
|
149
152
|
|
|
150
|
-
if (
|
|
151
|
-
|
|
152
|
-
return {
|
|
153
|
-
result: {
|
|
154
|
-
...resultObject,
|
|
155
|
-
result_handle: handle,
|
|
156
|
-
total_chars: text.length,
|
|
157
|
-
view,
|
|
158
|
-
view_path,
|
|
159
|
-
truncated: false,
|
|
160
|
-
expires_at,
|
|
161
|
-
warnings: [...warningsOf(resultObject), hint]
|
|
162
|
-
},
|
|
163
|
-
stored: true
|
|
164
|
-
};
|
|
153
|
+
if (toolName === 'extract_embedded_state') {
|
|
154
|
+
return { result: shapeEmbeddedState(resultObject, { json, text, maxInline, handle, expires_at, readWith }), stored: true };
|
|
165
155
|
}
|
|
166
156
|
|
|
167
157
|
const preview = text.slice(0, maxInline);
|
|
@@ -200,3 +190,75 @@ export function applyInlineThreshold(toolName, resultObject, params, { store, en
|
|
|
200
190
|
});
|
|
201
191
|
return { result: shaped, stored: true };
|
|
202
192
|
}
|
|
193
|
+
|
|
194
|
+
/** Object.keys(data) for a plain object, "array(<n>)" for an array, null otherwise. */
|
|
195
|
+
export function embeddedDataKeys(data) {
|
|
196
|
+
if (Array.isArray(data)) return `array(${data.length})`;
|
|
197
|
+
if (data !== null && typeof data === 'object') return Object.keys(data);
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* The longest run of whole lines from the start of `text` whose JSON-string
|
|
203
|
+
* form fits in `budget` characters. A line that does not fit ends the preview,
|
|
204
|
+
* so it never stops inside a value; with a budget too small for the first
|
|
205
|
+
* line it is empty.
|
|
206
|
+
*/
|
|
207
|
+
export function wholeLinePreview(text, budget) {
|
|
208
|
+
const room = budget - 2; // the string's own quotes
|
|
209
|
+
let used = 0;
|
|
210
|
+
let end = 0;
|
|
211
|
+
let start = 0;
|
|
212
|
+
while (start < text.length) {
|
|
213
|
+
const newline = text.indexOf('\n', start);
|
|
214
|
+
const stop = newline === -1 ? text.length : newline;
|
|
215
|
+
// A line's escaped length, plus the escaped newline that joins it to the last.
|
|
216
|
+
const cost = JSON.stringify(text.slice(start, stop)).length - 2 + (end > 0 ? 2 : 0);
|
|
217
|
+
if (used + cost > room) break;
|
|
218
|
+
used += cost;
|
|
219
|
+
end = stop;
|
|
220
|
+
start = stop + 1;
|
|
221
|
+
}
|
|
222
|
+
return text.slice(0, end);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* extract_embedded_state over the limit (plan Phase 3.1). What stays inline is
|
|
227
|
+
* everything that says what the page carries — url, path, bytes, the whole
|
|
228
|
+
* `found` list, data_keys, the escalation report and window_state's list —
|
|
229
|
+
* then a preview of the pretty-printed JSON cut at a line boundary. The
|
|
230
|
+
* preview gets whatever max_inline_chars leaves after the rest, measured as
|
|
231
|
+
* compact JSON, so the whole inline result stays within the limit whenever
|
|
232
|
+
* the rest fits. The full result is under the handle for read_result
|
|
233
|
+
* json_path. The REST route shapes it the same way; keep the two in step.
|
|
234
|
+
*/
|
|
235
|
+
function shapeEmbeddedState(resultObject, { json, text, maxInline, handle, expires_at, readWith }) {
|
|
236
|
+
const dataKeys = embeddedDataKeys(resultObject.data);
|
|
237
|
+
const firstKey = Array.isArray(dataKeys) && dataKeys.length > 0 ? dataKeys[0] : null;
|
|
238
|
+
const example = firstKey && /^[A-Za-z_$][\w$]*$/.test(firstKey) ? ` Read one payload with operation "json_path", path "data.${firstKey}".` : '';
|
|
239
|
+
const hint = `Result is ${json.length} chars as JSON, over the inline limit of ${maxInline}; preview holds the pretty-printed JSON up to the last whole line that fits (${text.length} chars in total), data_keys lists the top-level keys of data, and the full result is kept for 1 hour under result_handle ${handle}: ${readWith}.${example} Or re-run with path, or keys_only:true, to ask for less.`;
|
|
240
|
+
|
|
241
|
+
const shaped = {};
|
|
242
|
+
for (const key of ['url', 'path', 'bytes', 'found']) {
|
|
243
|
+
if (key in resultObject) shaped[key] = resultObject[key];
|
|
244
|
+
}
|
|
245
|
+
shaped.data_keys = dataKeys;
|
|
246
|
+
if ('escalated' in resultObject) shaped.escalated = resultObject.escalated;
|
|
247
|
+
if (resultObject.stealth) shaped.stealth = resultObject.stealth;
|
|
248
|
+
if (resultObject.window_state && typeof resultObject.window_state === 'object') {
|
|
249
|
+
shaped.window_state = { note: resultObject.window_state.note, found: resultObject.window_state.found };
|
|
250
|
+
}
|
|
251
|
+
const tail = {
|
|
252
|
+
result_handle: handle,
|
|
253
|
+
total_chars: text.length,
|
|
254
|
+
view: 'json',
|
|
255
|
+
view_path: null,
|
|
256
|
+
truncated: true,
|
|
257
|
+
expires_at,
|
|
258
|
+
warnings: [...warningsOf(resultObject), hint]
|
|
259
|
+
};
|
|
260
|
+
// `"preview":"",` is what the key itself adds once the value is in.
|
|
261
|
+
const envelope = JSON.stringify({ ...shaped, preview: '', ...tail }).length;
|
|
262
|
+
const preview = wholeLinePreview(text, Math.max(0, maxInline - envelope + 2));
|
|
263
|
+
return { ...shaped, preview, ...tail };
|
|
264
|
+
}
|
|
@@ -22,7 +22,7 @@ metered; there is no free tier. Tools marked "scales" cost more as work grows.
|
|
|
22
22
|
|------|-------|
|
|
23
23
|
| `scrape` | Unified multi-format single fetch. A `highlights` or `question` format adds 1 once per call; `mode:"model"` on one of them adds 3 once. `escalate:true` adds 5 as the projected ceiling; the actual charge drops back to the base when the plain fetch succeeded and no escalation ran. |
|
|
24
24
|
| `scrape_structured` | CSS-selector extraction. |
|
|
25
|
-
| `extract_embedded_state` | Embedded JS state (`__NEXT_DATA__`, RSC, Nuxt, Apollo, Redux). |
|
|
25
|
+
| `extract_embedded_state` | Embedded JS state (`__NEXT_DATA__`, RSC, Nuxt, Apollo, Redux). `escalate:true` adds 5 as the projected ceiling for the stealth re-read; the actual charge drops back to 2 when the plain fetch succeeded and no escalation ran. `keys_only` and `find` cost nothing extra. |
|
|
26
26
|
| `extract_content` | Readability-cleaned article. |
|
|
27
27
|
| `map_site` | URL discovery / sitemap. |
|
|
28
28
|
| `process_document` | PDF / DOCX / TXT parsing. |
|
|
@@ -36,15 +36,34 @@ already, exact and typed — that beats asking a model to read them off the rend
|
|
|
36
36
|
{ "tool": "extract_embedded_state", "params": { "url": "https://www.ticketmaster.com/discover/concerts", "path": "next_data.props.pageProps" } }
|
|
37
37
|
```
|
|
38
38
|
|
|
39
|
-
Finds `__NEXT_DATA__`, `self.__next_f` (React Server Component payloads
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
39
|
+
Finds `__NEXT_DATA__`, `self.__next_f` (React Server Component payloads, with
|
|
40
|
+
`$<id>` references between rows resolved and a `data_rows` index of the rows
|
|
41
|
+
carrying data), `window.__NUXT__` and Nuxt 3's `__NUXT_DATA__` (decoded as
|
|
42
|
+
`nuxt_data`; `raw: true` also keeps the undecoded array), SvelteKit `kit.start`
|
|
43
|
+
data, `__APOLLO_STATE__`, `__INITIAL_STATE__`, `__PRELOADED_STATE__`,
|
|
44
|
+
`ytInitialData`, `ytInitialPlayerResponse`, Inertia `data-page`, Shopify meta and
|
|
45
|
+
product JSON, JSON `data-*` attributes and `<script type="application/json">`
|
|
46
|
+
blocks, keyed by source name. No LLM in the path, so values are exact rather
|
|
47
|
+
than inferred.
|
|
43
48
|
|
|
44
49
|
These payloads are routinely over a megabyte — pass `path` (dotted keys and
|
|
45
50
|
array indexes, e.g. `next_data.props.pageProps` or `next_f[0].f`) to return one
|
|
46
|
-
subtree
|
|
47
|
-
|
|
51
|
+
subtree, or `keys_only: true` first to see the first two levels of keys (types,
|
|
52
|
+
no values) before choosing one. When you know a field's name but not where it
|
|
53
|
+
is, `find: "<key>"` returns every path holding it (case-insensitive, up to 50,
|
|
54
|
+
each with a 200-character preview) instead of the data; each path can be passed
|
|
55
|
+
straight back as `path`. `find` and `keys_only` cannot be combined. A result
|
|
56
|
+
over `max_inline_chars` comes back as a `preview` (whole lines of the JSON),
|
|
57
|
+
`data_keys` and a `result_handle`: read the
|
|
58
|
+
rest with `read_result` operation `json_path` (e.g. path `data.next_data.props`)
|
|
59
|
+
instead of fetching again.
|
|
60
|
+
|
|
61
|
+
On a site that blocks a plain fetch (403/429/challenge page), pass
|
|
62
|
+
`escalate: true` (projected 7, charged 2 when the plain fetch works): the stealth
|
|
63
|
+
browser re-reads the page, the same parser runs on it, and the framework globals
|
|
64
|
+
are read off `window` after JavaScript ran — `ytInitialData`, `__NUXT__`,
|
|
65
|
+
`__remixContext` and others, under `window_state` (path
|
|
66
|
+
`window_state.ytInitialData.contents`). A 404 or 5xx never escalates.
|
|
48
67
|
|
|
49
68
|
## scrape_template — known sites, zero selectors (cost: 1)
|
|
50
69
|
|