crawlforge-mcp-server 6.23.1 → 6.24.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/package.json +1 -1
- package/server.js +21 -11
- package/src/core/LLMsTxtAnalyzer.js +4 -1
- package/src/core/pageCache.js +249 -0
- package/src/schemas/toolOutputSchemas.js +18 -0
- package/src/server/inlineThreshold.js +12 -5
- package/src/server/requestContext.js +40 -0
- package/src/server/withAuth.js +84 -9
- package/src/skills/agent-skills/crawlforge-batch-automation/SKILL.md +1 -1
- package/src/skills/agent-skills/crawlforge-browser-sessions/SKILL.md +1 -1
- package/src/skills/agent-skills/crawlforge-change-tracking/SKILL.md +1 -1
- package/src/skills/agent-skills/crawlforge-deep-research/SKILL.md +1 -1
- package/src/skills/agent-skills/crawlforge-getting-started/SKILL.md +4 -1
- package/src/skills/agent-skills/crawlforge-getting-started/references/credits.md +4 -0
- package/src/skills/agent-skills/crawlforge-stealth-browsing/SKILL.md +1 -1
- package/src/skills/agent-skills/crawlforge-structured-extraction/SKILL.md +1 -1
- package/src/skills/agent-skills/crawlforge-web-scraping/SKILL.md +1 -1
- package/src/tools/basic/_fetch.js +35 -4
- package/src/tools/basic/extractLinks.js +3 -2
- package/src/tools/basic/extractMetadata.js +3 -2
- package/src/tools/basic/extractText.js +3 -2
- package/src/tools/basic/fetchUrl.js +3 -2
- package/src/tools/basic/scrapeStructured.js +3 -2
- package/src/tools/extract/_fetchAndParse.js +47 -18
- package/src/tools/extract/extractEmbeddedState.js +58 -7
- package/src/tools/extract/extractStructured.js +4 -1
- package/src/tools/extract/extractWithLlm.js +3 -0
- package/src/tools/extract/processDocument.js +12 -5
- package/src/tools/llmstxt/generateLLMsTxt.js +25 -14
- package/src/tools/scrape/unifiedScrape.js +4 -1
- package/src/utils/canonicalUrl.js +29 -0
- package/src/utils/fetchLadder.js +3 -3
- package/src/utils/maxAgeParam.js +18 -0
- package/src/utils/robotsGate.js +24 -5
- package/src/utils/ssrfGuard.js +2 -1
package/src/server/withAuth.js
CHANGED
|
@@ -7,7 +7,11 @@
|
|
|
7
7
|
* - resolves toolCost once per call; every tool is metered (no free tier),
|
|
8
8
|
* so a valid API key is required for every invocation
|
|
9
9
|
* - try/finally guarantees a single `tool invocation` log line per call
|
|
10
|
-
* - log payload: { toolName, paramHash, durationMs, outcome, creditCost, creatorMode
|
|
10
|
+
* - log payload: { toolName, paramHash, durationMs, outcome, creditCost, creatorMode,
|
|
11
|
+
* internal, runId, client, source, urlKey, cache } — runId is per server process
|
|
12
|
+
* (CRAWLFORGE_RUN_ID overrides), client is "<name>/<version>" from initialize,
|
|
13
|
+
* source ∈ { 'client' | 'test' | 'harness' | 'eval' }, urlKey is a hash of the
|
|
14
|
+
* canonical URL (never the URL itself), cache ∈ { 'hit' | 'miss' | 'bypass' | null }
|
|
11
15
|
* - outcome ∈ { 'success' | 'error' | 'insufficient_credits' | 'input_required' }
|
|
12
16
|
* - an `input_required` return (Phase 4.4) is a round trip, not an answer: the
|
|
13
17
|
* handler did no work, so it is billed NOTHING and reports no usage. The
|
|
@@ -15,21 +19,59 @@
|
|
|
15
19
|
* once, so a confirmation costs exactly what the call always cost (G4).
|
|
16
20
|
* - a failed call (an isError result, a JSON body with success:false, or a
|
|
17
21
|
* throw) is charged NOTHING and reports no usage: failures are free
|
|
22
|
+
* - a call whose every page fetch was served from the page cache
|
|
23
|
+
* (src/core/pageCache.js) costs at most 1 credit (D1), says so in `cache`
|
|
24
|
+
* and `_cost.projection_note`, and logs `cache: 'hit'`
|
|
18
25
|
* - error results get a "Next step:" hint naming the tool to try next
|
|
19
26
|
* (src/server/fallbackHints.js) so a failure is not followed by a blind retry
|
|
20
27
|
* - emits an OTel span via src/observability/tracing.js (no-op if disabled)
|
|
21
28
|
* - increments Prometheus counters via src/observability/metrics.js (if registry passed)
|
|
22
29
|
*/
|
|
23
30
|
|
|
24
|
-
import { createHash } from 'node:crypto';
|
|
31
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
25
32
|
import { isInputRequiredResult } from '@modelcontextprotocol/server';
|
|
26
33
|
import { recordToolInvocation } from '../observability/tracing.js';
|
|
27
|
-
import { isInternalRequest, reportedActualCost, requestContext } from './requestContext.js';
|
|
34
|
+
import { isInternalRequest, pageCacheSummary, reportedActualCost, requestContext, servingServer } from './requestContext.js';
|
|
28
35
|
import { appendFallbackHint } from './fallbackHints.js';
|
|
29
36
|
import { failure, normalizeErrorResult } from './errorText.js';
|
|
30
|
-
import { INLINE_THRESHOLD_TOOLS, applyInlineThreshold } from './inlineThreshold.js';
|
|
37
|
+
import { CACHE_BLOCK_RESERVE, COST_BLOCK_RESERVE, INLINE_THRESHOLD_TOOLS, applyInlineThreshold } from './inlineThreshold.js';
|
|
31
38
|
import { REDACTION_TOOLS, redactionSurcharge, runRedactionStage } from './redaction.js';
|
|
32
39
|
import { getResultStore } from '../core/ResultStore.js';
|
|
40
|
+
import { canonicalUrl } from '../utils/canonicalUrl.js';
|
|
41
|
+
|
|
42
|
+
// One id per server process; over stdio that is one client session.
|
|
43
|
+
const PROCESS_RUN_ID = randomUUID().slice(0, 8);
|
|
44
|
+
const LOG_SOURCES = new Set(['harness', 'eval']);
|
|
45
|
+
// A cache hit costs at most this much (D1). agent is left out: its price pays
|
|
46
|
+
// for searches and model steps the page cache does not cover.
|
|
47
|
+
const CACHE_HIT_CREDITS = 1;
|
|
48
|
+
const CACHE_HIT_EXCLUDED = new Set(['agent']);
|
|
49
|
+
|
|
50
|
+
/** Who made this call: a test run, a named harness, or a real client. */
|
|
51
|
+
function logSource() {
|
|
52
|
+
if (process.env.NODE_ENV === 'test') return 'test';
|
|
53
|
+
const declared = process.env.CRAWLFORGE_LOG_SOURCE;
|
|
54
|
+
return LOG_SOURCES.has(declared) ? declared : 'client';
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** First 12 hex chars of sha256(canonical URL); null when there is no single URL. */
|
|
58
|
+
export function urlKey(url) {
|
|
59
|
+
const canonical = canonicalUrl(url);
|
|
60
|
+
return canonical ? createHash('sha256').update(canonical).digest('hex').slice(0, 12) : null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* "<name>/<version>" from the client's initialize. Over HTTP each session (or
|
|
65
|
+
* 2026-era request) is served by its own clone, stamped on the request context.
|
|
66
|
+
*/
|
|
67
|
+
function clientLabel(mcpServer) {
|
|
68
|
+
try {
|
|
69
|
+
const info = (servingServer() ?? mcpServer)?.server?.getClientVersion?.();
|
|
70
|
+
return info?.name ? `${info.name}/${info.version ?? ''}` : null;
|
|
71
|
+
} catch {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
33
75
|
|
|
34
76
|
/**
|
|
35
77
|
* Replace an over-threshold JSON text result (and its structuredContent, if
|
|
@@ -39,7 +81,9 @@ function shapeLargeResult(toolName, result, params) {
|
|
|
39
81
|
if (!result || !Array.isArray(result.content) || result.content[0]?.type !== 'text') return;
|
|
40
82
|
let parsed;
|
|
41
83
|
try { parsed = JSON.parse(result.content[0].text); } catch { return; }
|
|
42
|
-
|
|
84
|
+
// A call that went through the page cache also gets a `cache` block after shaping.
|
|
85
|
+
const reserve = COST_BLOCK_RESERVE + (pageCacheSummary() ? CACHE_BLOCK_RESERVE : 0);
|
|
86
|
+
const { result: shaped, stored } = applyInlineThreshold(toolName, parsed, params, { store: getResultStore(), reserve });
|
|
43
87
|
if (!stored) return;
|
|
44
88
|
result.content[0].text = JSON.stringify(shaped, null, 2);
|
|
45
89
|
if (result.structuredContent) result.structuredContent = shaped;
|
|
@@ -63,6 +107,11 @@ async function redactResult(toolName, result, params, mcpServer) {
|
|
|
63
107
|
return modelRan;
|
|
64
108
|
}
|
|
65
109
|
|
|
110
|
+
/** The one line a cache hit adds to the result (plan 1.6). */
|
|
111
|
+
export function cacheHitLine(ageMs) {
|
|
112
|
+
return `Served from a copy of this page fetched ${Math.round((ageMs ?? 0) / 1000)} s ago in this session (pass maxAge: 0 for a fresh fetch).`;
|
|
113
|
+
}
|
|
114
|
+
|
|
66
115
|
export function hashParams(params) {
|
|
67
116
|
try {
|
|
68
117
|
return createHash('sha256').update(JSON.stringify(params ?? {})).digest('hex').slice(0, 12);
|
|
@@ -101,9 +150,16 @@ export function makeWithAuth({ authManager, logger, metrics = null, mcpServer =
|
|
|
101
150
|
// A handler may report a lower spend (setActualCost). The projection is
|
|
102
151
|
// the ceiling the caller saw before the call, so a report can only ever
|
|
103
152
|
// lower the charge, never raise it.
|
|
153
|
+
// A page served from the cache costs CACHE_HIT_CREDITS in place of the
|
|
154
|
+
// tool's base price (D1). Only the base price is discounted: surcharges
|
|
155
|
+
// for work that still ran on the cached page (scrape's query and model
|
|
156
|
+
// formats) are still charged.
|
|
157
|
+
let cacheHitPrice = false;
|
|
158
|
+
const cacheHitDiscount = Math.max(0, authManager.getToolCost(toolName, {}) - CACHE_HIT_CREDITS);
|
|
104
159
|
const billable = () => {
|
|
105
160
|
const reported = reportedActualCost();
|
|
106
|
-
|
|
161
|
+
let spent = reported == null ? toolCost : Math.min(reported, toolCost);
|
|
162
|
+
if (cacheHitPrice) spent = Math.max(Math.min(spent, CACHE_HIT_CREDITS), spent - cacheHitDiscount);
|
|
107
163
|
return spent + (redactionModelRan ? redactionCost : 0);
|
|
108
164
|
};
|
|
109
165
|
|
|
@@ -184,6 +240,9 @@ export function makeWithAuth({ authManager, logger, metrics = null, mcpServer =
|
|
|
184
240
|
try { shapeLargeResult(toolName, result, params); } catch { /* shaping must never break the request path */ }
|
|
185
241
|
}
|
|
186
242
|
|
|
243
|
+
const cache = isErrorResult ? null : pageCacheSummary();
|
|
244
|
+
cacheHitPrice = cache?.state === 'hit' && !CACHE_HIT_EXCLUDED.has(toolName);
|
|
245
|
+
|
|
187
246
|
// Failures are free, as the website promises. A pre-flight refusal that
|
|
188
247
|
// sank the call is one of them; a multi-URL tool that skipped one
|
|
189
248
|
// disallowed URL and still returned a result is a success and bills.
|
|
@@ -202,7 +261,14 @@ export function makeWithAuth({ authManager, logger, metrics = null, mcpServer =
|
|
|
202
261
|
projected: creditCost,
|
|
203
262
|
actual: creatorMode ? 0 : charge,
|
|
204
263
|
remaining_credits: remainingCredits,
|
|
205
|
-
projection_note:
|
|
264
|
+
projection_note: cacheHitPrice && charge > 0
|
|
265
|
+
? `${projection.note} Served from this session's page cache: charged ${charge} instead of ${creditCost}.`
|
|
266
|
+
: projection.note
|
|
267
|
+
};
|
|
268
|
+
// 1.6: say where the page came from, and on a hit that a fresh fetch is one parameter away.
|
|
269
|
+
const cacheMeta = cache && {
|
|
270
|
+
...cache,
|
|
271
|
+
...(cache.state === 'hit' ? { note: cacheHitLine(cache.ageMs) } : {})
|
|
206
272
|
};
|
|
207
273
|
|
|
208
274
|
// Inject _cost into the first text content item if it's JSON
|
|
@@ -210,14 +276,17 @@ export function makeWithAuth({ authManager, logger, metrics = null, mcpServer =
|
|
|
210
276
|
try {
|
|
211
277
|
const parsed = JSON.parse(result.content[0].text);
|
|
212
278
|
parsed._cost = costMeta;
|
|
279
|
+
if (cacheMeta) parsed.cache = cacheMeta;
|
|
213
280
|
result.content[0].text = JSON.stringify(parsed, null, 2);
|
|
214
281
|
} catch {
|
|
215
|
-
// Not JSON
|
|
282
|
+
// Not JSON: the hit line goes on the end of the text instead.
|
|
283
|
+
if (cacheMeta?.note) result.content[0].text += `\n${cacheMeta.note}`;
|
|
216
284
|
}
|
|
217
285
|
}
|
|
218
286
|
// ...and into structuredContent, which is all some clients show (R25 0.2).
|
|
219
287
|
if (result?.structuredContent && typeof result.structuredContent === 'object') {
|
|
220
288
|
result.structuredContent._cost = costMeta;
|
|
289
|
+
if (cacheMeta) result.structuredContent.cache = cacheMeta;
|
|
221
290
|
}
|
|
222
291
|
} catch {
|
|
223
292
|
// Cost injection must never break the request path
|
|
@@ -255,7 +324,12 @@ export function makeWithAuth({ authManager, logger, metrics = null, mcpServer =
|
|
|
255
324
|
outcome,
|
|
256
325
|
creditCost,
|
|
257
326
|
creatorMode,
|
|
258
|
-
internal
|
|
327
|
+
internal,
|
|
328
|
+
runId: process.env.CRAWLFORGE_RUN_ID || PROCESS_RUN_ID,
|
|
329
|
+
client: clientLabel(mcpServer),
|
|
330
|
+
source: logSource(),
|
|
331
|
+
urlKey: urlKey(params?.url),
|
|
332
|
+
cache: pageCacheSummary()?.state ?? null
|
|
259
333
|
});
|
|
260
334
|
|
|
261
335
|
// Prometheus (no-op unless registry was supplied)
|
|
@@ -304,6 +378,7 @@ export function makeWithAuth({ authManager, logger, metrics = null, mcpServer =
|
|
|
304
378
|
...(requestContext.getStore() ?? {}),
|
|
305
379
|
preflightRefusal: null,
|
|
306
380
|
actualCost: null,
|
|
381
|
+
pageCache: null,
|
|
307
382
|
servingRequestId: ctx?.mcpReq?.id
|
|
308
383
|
},
|
|
309
384
|
() => invoke(params, ctx)
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
name: crawlforge-batch-automation
|
|
3
3
|
description: "Automates large scraping jobs and browser interactions with CrawlForge's batch_scrape, get_batch_results, scrape_with_actions, and generate_llms_txt tools. Use when the user wants to scrape many URLs at once, batch-scrape a list of pages, collect dozens of product, news, or competitor pages, run browser actions (click, type, scroll, wait) before scraping, log in or fill a form before extracting, or generate an llms.txt for a site. Use sync mode for up to about 25 URLs and async with a webhook for large batches; retrieve paginated output with get_batch_results."
|
|
4
4
|
metadata:
|
|
5
|
-
version: 6.
|
|
5
|
+
version: 6.24.0
|
|
6
6
|
source: crawlforge-mcp-server
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
name: crawlforge-browser-sessions
|
|
3
3
|
description: "Keeps one browser page alive across several tool calls with CrawlForge's browser_session tool. Use when the user needs to log in and then read pages behind that login, work through a multi-step flow (search, filter, paginate, fill a wizard), or look at a page before deciding what to click. The loop is: open a session on a URL, snapshot it to list the interactive elements with stable refs (@e1, @e2), act on those refs, read the content, close. Unlike scrape_with_actions, which is one-shot and closes its browser when the call returns, a session survives between calls, so one login is paid for once and a wrong selector costs one call instead of a whole chain."
|
|
4
4
|
metadata:
|
|
5
|
-
version: 6.
|
|
5
|
+
version: 6.24.0
|
|
6
6
|
source: crawlforge-mcp-server
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
name: crawlforge-change-tracking
|
|
3
3
|
description: "Monitors web pages for changes over time with CrawlForge's track_changes tool. Use when the user wants to track changes to a page, watch a URL, monitor competitor pricing, detect when content updates, get notified of regulation or product-availability changes, or diff a page against a saved baseline. Workflow: create a baseline with operation create_baseline, then periodically compare with operation compare to get a change percentage and a diff; supports CSS-selector scoping, webhooks, and scheduled monitoring."
|
|
4
4
|
metadata:
|
|
5
|
-
version: 6.
|
|
5
|
+
version: 6.24.0
|
|
6
6
|
source: crawlforge-mcp-server
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
name: crawlforge-deep-research
|
|
3
3
|
description: "Runs multi-source web research and autonomous question-answering with CrawlForge's deep_research, agent, search_web, and reddit_search tools. Use when the user wants to research a topic, do a deep dive, compare competitors, gather facts with citations, answer a question from the web, search the web, or get a synthesized report from many sources, or search Reddit posts, comments, and threads. deep_research expands queries, fetches and dedupes sources, then synthesizes; agent autonomously plans searches and navigation from a plain-English prompt with no URLs needed; search_web returns ranked results; reddit_search searches Reddit via community archives (reddit.com blocks scrapers). Caps costs via max_urls and confirms before expensive runs."
|
|
4
4
|
metadata:
|
|
5
|
-
version: 6.
|
|
5
|
+
version: 6.24.0
|
|
6
6
|
source: crawlforge-mcp-server
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
name: crawlforge-getting-started
|
|
3
3
|
description: "Orientation and tool-selection guide for the CrawlForge MCP server's 31 web tools. Use when the user is getting started with CrawlForge, asks which CrawlForge tool to use, how to set up the API key, how skills or the CLI work, what a tool costs in credits, or when one tool fails and a fallback is needed. Routes requests to the right specialized skill (web scraping, deep research, stealth, structured extraction, change tracking, batch automation, browser sessions), and explains MCP-tools-vs-CLI, the Ollama-first LLM fallback chain, and per-tool credit costs."
|
|
4
4
|
metadata:
|
|
5
|
-
version: 6.
|
|
5
|
+
version: 6.24.0
|
|
6
6
|
source: crawlforge-mcp-server
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -108,3 +108,6 @@ get_batch_results...) up to 10+ for `deep_research`. Full table:
|
|
|
108
108
|
[credits](references/credits.md). Prefer the cheapest tool that does the job, and
|
|
109
109
|
cap dynamic tools (`deep_research`, `agent`, `crawl_deep`) with their max
|
|
110
110
|
parameters.
|
|
111
|
+
In a local session, asking again for the same page with different formats, paths or
|
|
112
|
+
selectors reuses this session's copy for 10 minutes at 1 credit; pass `maxAge: 0` to
|
|
113
|
+
force a fresh fetch.
|
|
@@ -81,6 +81,10 @@ metered; there is no free tier. Tools marked "scales" cost more as work grows.
|
|
|
81
81
|
when the whole interaction can be written down in advance.
|
|
82
82
|
- A result over `max_inline_chars` costs nothing extra; read the stored copy with
|
|
83
83
|
`read_result` (1) instead of fetching again.
|
|
84
|
+
- In a local (stdio) session, asking again for a page read in the last 10 minutes
|
|
85
|
+
(`fetch_url`, `scrape`, `extract_*`, `scrape_structured`, `extract_structured`,
|
|
86
|
+
`extract_with_llm`) with different formats, paths or selectors reuses the copy and
|
|
87
|
+
costs 1 credit; `maxAge: 0` forces a fresh fetch at the full price.
|
|
84
88
|
- On a site that blocks, one `scrape` with `escalate:true` costs at most 7 and
|
|
85
89
|
only 2 when the plain fetch works; `scrape` then `stealth_mode` costs 7 either
|
|
86
90
|
way. Do not set it on a site that reads fine — it raises the projection your
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
name: crawlforge-stealth-browsing
|
|
3
3
|
description: "Bypasses bot detection with CrawlForge's stealth_mode tool and looks up a country's locale settings with localization. Use when a site returns 403 or 429, CAPTCHAs, 'please enable JavaScript', or empty content, or is protected by Cloudflare, DataDome, or PerimeterX, or when the user needs a page in a specific locale, language, or timezone. stealth_mode runs a stealth browser (engine auto by default: camoufox/Firefox for advanced fingerprinting, playwright/Chromium when pinned for speed) and can screenshot; localization returns a country's Accept-Language, timezone and currency for you to pass to fetch_url or stealth_mode - it applies nothing itself and uses no proxy. Explains when to escalate from a normal scrape to stealth, and why a hard block needs a residential proxy."
|
|
4
4
|
metadata:
|
|
5
|
-
version: 6.
|
|
5
|
+
version: 6.24.0
|
|
6
6
|
source: crawlforge-mcp-server
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
name: crawlforge-structured-extraction
|
|
3
3
|
description: "Extracts structured JSON and analyzes content with CrawlForge's extract_structured, extract_with_llm, extract_embedded_state, scrape_structured, scrape_template, process_document, analyze_content, summarize_content, and list_ollama_models tools. Use when the user wants to extract specific fields, pull data into a JSON schema, extract by natural-language prompt, scrape with CSS selectors, read a page's embedded JavaScript state (__NEXT_DATA__, React Server Components, Nuxt, Apollo, Redux), get product, profile, or repo data from known sites (Amazon, LinkedIn, GitHub, YouTube, Reddit, and more), parse a PDF or DOCX, summarize a page, or analyze sentiment, entities, or keywords. Defaults to local Ollama for LLM extraction; OpenAI and Anthropic optional."
|
|
4
4
|
metadata:
|
|
5
|
-
version: 6.
|
|
5
|
+
version: 6.24.0
|
|
6
6
|
source: crawlforge-mcp-server
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
name: crawlforge-web-scraping
|
|
3
3
|
description: "Scrapes web pages and returns clean Markdown, HTML, plain text, links, or metadata using CrawlForge's scrape, fetch_url, extract_content, extract_text, extract_links, extract_metadata, map_site, and crawl_deep tools. Use when the user wants to scrape a URL, fetch a page, get the markdown or text of a website, extract links or metadata, read an article, map a site's URLs, generate a sitemap, or crawl a whole website or documentation site. Prefer the unified scrape tool to get multiple formats in one call; use map_site to discover URLs and crawl_deep to walk an entire site."
|
|
4
4
|
metadata:
|
|
5
|
-
version: 6.
|
|
5
|
+
version: 6.24.0
|
|
6
6
|
source: crawlforge-mcp-server
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -10,6 +10,7 @@ import { noteRetryAfter } from '../../utils/hostRateLimiter.js';
|
|
|
10
10
|
import { preflightFetch } from '../../utils/robotsGate.js';
|
|
11
11
|
import { fetchResigned } from '../../utils/resignedFetch.js';
|
|
12
12
|
import { withFetchCause } from '../../utils/fetchError.js';
|
|
13
|
+
import { pageFromResponse, throughPageCache } from '../../core/pageCache.js';
|
|
13
14
|
|
|
14
15
|
/**
|
|
15
16
|
* Fetch a URL with a configurable timeout and body-size cap.
|
|
@@ -19,12 +20,39 @@ import { withFetchCause } from '../../utils/fetchError.js';
|
|
|
19
20
|
* configurable cap from config.fetch.maxBodySize (env MAX_FETCH_BODY_SIZE,
|
|
20
21
|
* default 25 MB).
|
|
21
22
|
*
|
|
23
|
+
* The page goes through the process page cache (src/core/pageCache.js):
|
|
24
|
+
* `maxAge` (ms, default 10 min, 0 = fetch fresh) and `noCache` steer it. A
|
|
25
|
+
* copy served from it is a Response-like object with the same fields.
|
|
26
|
+
*
|
|
22
27
|
* @param {string} url
|
|
23
28
|
* @param {{ timeout?: number, headers?: Record<string,string>, userAgent?: string,
|
|
24
|
-
* respectRobots?: boolean, tool?: string, apiKey?: string
|
|
29
|
+
* respectRobots?: boolean, tool?: string, apiKey?: string, maxAge?: number,
|
|
30
|
+
* noCache?: boolean }} [options]
|
|
25
31
|
* @returns {Promise<Response & { _body: string, _warnings: string[] }>}
|
|
26
32
|
*/
|
|
27
|
-
export
|
|
33
|
+
export function fetchWithTimeout(url, options = {}) {
|
|
34
|
+
return throughPageCache(url, options, 'plain', () => fetchFresh(url, options), responseFromPage);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** A stored page as the Response-like object fetchFresh returns. */
|
|
38
|
+
function responseFromPage(page, warnings) {
|
|
39
|
+
return {
|
|
40
|
+
ok: page.status >= 200 && page.status <= 299,
|
|
41
|
+
status: page.status,
|
|
42
|
+
statusText: page.statusText,
|
|
43
|
+
url: page.url,
|
|
44
|
+
redirected: page.redirected,
|
|
45
|
+
headers: new Headers(page.headers),
|
|
46
|
+
text: () => Promise.resolve(page.body),
|
|
47
|
+
json: () => Promise.resolve(JSON.parse(page.body)),
|
|
48
|
+
_body: page.body,
|
|
49
|
+
_responseTime: page.responseTime,
|
|
50
|
+
_warnings: warnings
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The network fetch; resolves to { result: the Response, page: what the cache may keep }. */
|
|
55
|
+
async function fetchFresh(url, options) {
|
|
28
56
|
const { timeout = 10000, headers = {}, userAgent, respectRobots, tool, apiKey } = options;
|
|
29
57
|
const maxBodySize = config.fetch.maxBodySize;
|
|
30
58
|
|
|
@@ -91,13 +119,16 @@ export async function fetchWithTimeout(url, options = {}) {
|
|
|
91
119
|
}
|
|
92
120
|
|
|
93
121
|
// Attach the pre-read text so callers can call .text() on the result.
|
|
94
|
-
|
|
122
|
+
const responseTime = Date.now() - startedAt;
|
|
123
|
+
const page = { ...pageFromResponse(response, bodyText), responseTime };
|
|
124
|
+
const result = Object.assign(response, {
|
|
95
125
|
text: () => Promise.resolve(bodyText),
|
|
96
126
|
json: () => Promise.resolve(JSON.parse(bodyText)),
|
|
97
127
|
_body: bodyText,
|
|
98
|
-
_responseTime:
|
|
128
|
+
_responseTime: responseTime,
|
|
99
129
|
_warnings: gate.warnings
|
|
100
130
|
});
|
|
131
|
+
return { result, page };
|
|
101
132
|
} finally {
|
|
102
133
|
clearTimeout(timeoutId);
|
|
103
134
|
}
|
|
@@ -11,16 +11,17 @@ import { fetchLadder, ladderErrorResult, isJsonType } from '../../utils/fetchLad
|
|
|
11
11
|
|
|
12
12
|
/**
|
|
13
13
|
* @param {{ url: string, filter_external?: boolean, base_url?: string,
|
|
14
|
-
* user_agent?: string, respect_robots?: boolean,
|
|
14
|
+
* user_agent?: string, respect_robots?: boolean, maxAge?: number,
|
|
15
15
|
* escalate?: boolean, escalate_engine?: string }} params
|
|
16
16
|
* @param {Function} [escalateFetch] the stealth escalation stage
|
|
17
17
|
*/
|
|
18
|
-
async function extractLinks({ url, filter_external, base_url, user_agent, respect_robots, escalate, escalate_engine }, escalateFetch) {
|
|
18
|
+
async function extractLinks({ url, filter_external, base_url, user_agent, respect_robots, maxAge, escalate, escalate_engine }, escalateFetch) {
|
|
19
19
|
try {
|
|
20
20
|
const ladder = await fetchLadder(url, {
|
|
21
21
|
tool: 'extract_links',
|
|
22
22
|
userAgent: user_agent,
|
|
23
23
|
respectRobots: respect_robots,
|
|
24
|
+
maxAge,
|
|
24
25
|
escalate: escalate === true,
|
|
25
26
|
escalateEngine: escalate_engine,
|
|
26
27
|
escalateFetch
|
|
@@ -64,13 +64,14 @@ function redirectFields(requested, final) {
|
|
|
64
64
|
|
|
65
65
|
/**
|
|
66
66
|
* @param {{ url: string, user_agent?: string, respect_robots?: boolean,
|
|
67
|
-
* json_ld_types?: string[] }} params
|
|
67
|
+
* json_ld_types?: string[], maxAge?: number }} params
|
|
68
68
|
*/
|
|
69
|
-
export async function extractMetadataHandler({ url, user_agent, respect_robots, json_ld_types }) {
|
|
69
|
+
export async function extractMetadataHandler({ url, user_agent, respect_robots, json_ld_types, maxAge }) {
|
|
70
70
|
try {
|
|
71
71
|
const response = await fetchWithTimeout(url, {
|
|
72
72
|
userAgent: user_agent,
|
|
73
73
|
respectRobots: respect_robots,
|
|
74
|
+
maxAge,
|
|
74
75
|
tool: 'extract_metadata'
|
|
75
76
|
});
|
|
76
77
|
if (!response.ok) {
|
|
@@ -105,16 +105,17 @@ function isCut(text, maxLength) {
|
|
|
105
105
|
/**
|
|
106
106
|
* @param {{ url: string, remove_scripts?: boolean, remove_styles?: boolean,
|
|
107
107
|
* output_format?: "text"|"markdown", selector?: string, max_length?: number,
|
|
108
|
-
* user_agent?: string, respect_robots?: boolean,
|
|
108
|
+
* user_agent?: string, respect_robots?: boolean, maxAge?: number,
|
|
109
109
|
* escalate?: boolean, escalate_engine?: string }} params
|
|
110
110
|
* @param {Function} [escalateFetch] the stealth escalation stage
|
|
111
111
|
*/
|
|
112
|
-
async function extractText({ url, remove_scripts, remove_styles, output_format, selector, max_length, user_agent, respect_robots, escalate, escalate_engine }, escalateFetch) {
|
|
112
|
+
async function extractText({ url, remove_scripts, remove_styles, output_format, selector, max_length, user_agent, respect_robots, maxAge, escalate, escalate_engine }, escalateFetch) {
|
|
113
113
|
try {
|
|
114
114
|
const ladder = await fetchLadder(url, {
|
|
115
115
|
tool: 'extract_text',
|
|
116
116
|
userAgent: user_agent,
|
|
117
117
|
respectRobots: respect_robots,
|
|
118
|
+
maxAge,
|
|
118
119
|
escalate: escalate === true,
|
|
119
120
|
escalateEngine: escalate_engine,
|
|
120
121
|
escalateFetch
|
|
@@ -7,15 +7,16 @@ import { fetchWithTimeout } from './_fetch.js';
|
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
9
|
* @param {{ url: string, headers?: Record<string,string>, timeout?: number,
|
|
10
|
-
* user_agent?: string, respect_robots?: boolean }} params
|
|
10
|
+
* user_agent?: string, respect_robots?: boolean, maxAge?: number }} params
|
|
11
11
|
*/
|
|
12
|
-
export async function fetchUrlHandler({ url, headers, timeout, user_agent, respect_robots }) {
|
|
12
|
+
export async function fetchUrlHandler({ url, headers, timeout, user_agent, respect_robots, maxAge }) {
|
|
13
13
|
try {
|
|
14
14
|
const response = await fetchWithTimeout(url, {
|
|
15
15
|
timeout: timeout || 10000,
|
|
16
16
|
headers: headers || {},
|
|
17
17
|
userAgent: user_agent,
|
|
18
18
|
respectRobots: respect_robots,
|
|
19
|
+
maxAge,
|
|
19
20
|
tool: 'fetch_url'
|
|
20
21
|
});
|
|
21
22
|
|
|
@@ -139,13 +139,14 @@ function extractRows($, { row_selector, selectors, max_results, baseUrl }) {
|
|
|
139
139
|
|
|
140
140
|
/**
|
|
141
141
|
* @param {{ url: string, selectors: Record<string, string|{ selector: string, multiple?: boolean }>, row_selector?: string,
|
|
142
|
-
* max_results?: number, user_agent?: string, respect_robots?: boolean }} params
|
|
142
|
+
* max_results?: number, user_agent?: string, respect_robots?: boolean, maxAge?: number }} params
|
|
143
143
|
*/
|
|
144
|
-
export async function scrapeStructuredHandler({ url, selectors, row_selector, max_results, user_agent, respect_robots }) {
|
|
144
|
+
export async function scrapeStructuredHandler({ url, selectors, row_selector, max_results, user_agent, respect_robots, maxAge }) {
|
|
145
145
|
try {
|
|
146
146
|
const response = await fetchWithTimeout(url, {
|
|
147
147
|
userAgent: user_agent,
|
|
148
148
|
respectRobots: respect_robots,
|
|
149
|
+
maxAge,
|
|
149
150
|
tool: 'scrape_structured'
|
|
150
151
|
});
|
|
151
152
|
if (!response.ok) {
|
|
@@ -16,6 +16,7 @@ import { safeFetch } from '../../utils/ssrfGuard.js';
|
|
|
16
16
|
import { config } from '../../constants/config.js';
|
|
17
17
|
import { noteRetryAfter } from '../../utils/hostRateLimiter.js';
|
|
18
18
|
import { preflightFetch } from '../../utils/robotsGate.js';
|
|
19
|
+
import { pageFromResponse, pageHeader, throughPageCache } from '../../core/pageCache.js';
|
|
19
20
|
|
|
20
21
|
const DEFAULT_TIMEOUT_MS = 15000;
|
|
21
22
|
|
|
@@ -108,16 +109,60 @@ export { flattenText as flattenBodyText };
|
|
|
108
109
|
* @param {number} [options.timeoutMs]
|
|
109
110
|
* @param {string[]} [options.stripTags] — additional tags to strip (default: script, style, noscript, iframe, svg)
|
|
110
111
|
* @param {boolean} [options.errorDocuments] — return a non-2xx HTML/text body with its status instead of throwing
|
|
112
|
+
* @param {number} [options.maxAge] — page cache: reuse a copy at most this many ms old (default 10 min; 0 = fetch fresh)
|
|
113
|
+
* @param {boolean} [options.noCache] — page cache: neither read nor store
|
|
111
114
|
* @returns {Promise<{ html: string, $: import('cheerio').CheerioAPI, textContent: string, finalUrl: string, warnings: string[], status: number }>}
|
|
112
115
|
*/
|
|
113
116
|
export async function fetchAndParse(url, options = {}) {
|
|
117
|
+
const { stripTags = ['script', 'style', 'noscript', 'iframe', 'svg'] } = options;
|
|
118
|
+
|
|
119
|
+
// The page itself may come from the process page cache (src/core/pageCache.js);
|
|
120
|
+
// the parse below is always this call's own, so no two calls share a `$`.
|
|
121
|
+
const page = await throughPageCache(
|
|
122
|
+
url,
|
|
123
|
+
options,
|
|
124
|
+
'html',
|
|
125
|
+
async () => {
|
|
126
|
+
const fetched = await fetchPage(url, options);
|
|
127
|
+
return { result: fetched, page: fetched };
|
|
128
|
+
},
|
|
129
|
+
(entry, warnings) => ({ ...entry, warnings })
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
const html = page.body;
|
|
133
|
+
const { warnings, status } = page;
|
|
134
|
+
const finalUrl = page.url;
|
|
135
|
+
|
|
136
|
+
// text/plain and application/json aren't markup: running them through the
|
|
137
|
+
// HTML parser risks misinterpreting substrings (e.g. a "<script>" value
|
|
138
|
+
// inside a JSON string) as real tags and stripping/mangling content.
|
|
139
|
+
if (classifyContentType(pageHeader(page, 'content-type')) === 'text') {
|
|
140
|
+
return { html, $: load(''), textContent: html.trim(), finalUrl, warnings, status };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const $ = load(html);
|
|
144
|
+
|
|
145
|
+
if (stripTags.length > 0) {
|
|
146
|
+
$(stripTags.join(', ')).remove();
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const textContent = flattenText($);
|
|
150
|
+
|
|
151
|
+
return { html, $, textContent, finalUrl, warnings, status };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* The network fetch behind fetchAndParse: the gate, the request, the status
|
|
156
|
+
* and content-type checks and the capped body read.
|
|
157
|
+
* @returns {Promise<object>} the page as pageFromResponse gives it, plus `warnings`
|
|
158
|
+
*/
|
|
159
|
+
async function fetchPage(url, options) {
|
|
114
160
|
const {
|
|
115
161
|
userAgent,
|
|
116
162
|
respectRobots,
|
|
117
163
|
tool,
|
|
118
164
|
apiKey,
|
|
119
165
|
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
120
|
-
stripTags = ['script', 'style', 'noscript', 'iframe', 'svg'],
|
|
121
166
|
errorDocuments = false
|
|
122
167
|
} = options;
|
|
123
168
|
|
|
@@ -157,21 +202,5 @@ export async function fetchAndParse(url, options = {}) {
|
|
|
157
202
|
}
|
|
158
203
|
|
|
159
204
|
const html = await readTextWithSizeCap(response);
|
|
160
|
-
|
|
161
|
-
// text/plain and application/json aren't markup: running them through the
|
|
162
|
-
// HTML parser risks misinterpreting substrings (e.g. a "<script>" value
|
|
163
|
-
// inside a JSON string) as real tags and stripping/mangling content.
|
|
164
|
-
if (classification === 'text') {
|
|
165
|
-
return { html, $: load(''), textContent: html.trim(), finalUrl: response.url, warnings, status: response.status };
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
const $ = load(html);
|
|
169
|
-
|
|
170
|
-
if (stripTags.length > 0) {
|
|
171
|
-
$(stripTags.join(', ')).remove();
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
const textContent = flattenText($);
|
|
175
|
-
|
|
176
|
-
return { html, $, textContent, finalUrl: response.url, warnings, status: response.status };
|
|
205
|
+
return { ...pageFromResponse(response, html), warnings };
|
|
177
206
|
}
|