crawlforge-mcp-server 6.23.2 → 6.25.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 +4 -4
- package/server.js +21 -11
- package/src/core/ActionExecutor.js +14 -0
- package/src/core/pageCache.js +249 -0
- package/src/schemas/toolOutputSchemas.js +18 -1
- 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-brand-style-guide/SKILL.md +107 -0
- 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 +5 -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/skills/agent-skills/crawlforge-web-scraping/references/tool-reference.md +1 -0
- 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/scrape/_brandingColor.js +185 -0
- package/src/tools/scrape/_brandingComputed.js +262 -0
- package/src/tools/scrape/_brandingExtractor.js +62 -90
- package/src/tools/scrape/_brandingTokens.js +350 -0
- package/src/tools/scrape/unifiedScrape.js +61 -17
- 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
|
@@ -140,3 +140,43 @@ export function servingEra() {
|
|
|
140
140
|
export function servingRequestId() {
|
|
141
141
|
return requestContext.getStore()?.servingRequestId ?? null;
|
|
142
142
|
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Record one page-cache outcome for this invocation (src/core/pageCache.js):
|
|
146
|
+
* 'hit' served a stored copy, 'miss' fetched and may have stored, 'bypass'
|
|
147
|
+
* fetched fresh because the caller asked (maxAge 0, noCache) or the request
|
|
148
|
+
* may not be cached. A tool can fetch several pages, so outcomes are counted,
|
|
149
|
+
* with the oldest copy's fetch time.
|
|
150
|
+
*
|
|
151
|
+
* @param {'hit'|'miss'|'bypass'} state
|
|
152
|
+
* @param {number} [fetchedAt] ms since epoch
|
|
153
|
+
*/
|
|
154
|
+
export function recordPageCache(state, fetchedAt) {
|
|
155
|
+
const store = requestContext.getStore();
|
|
156
|
+
if (!store) return;
|
|
157
|
+
const seen = store.pageCache ??= { hit: 0, miss: 0, bypass: 0, fetchedAt: null };
|
|
158
|
+
seen[state] += 1;
|
|
159
|
+
if (Number.isFinite(fetchedAt) && (seen.fetchedAt === null || fetchedAt < seen.fetchedAt)) {
|
|
160
|
+
seen.fetchedAt = fetchedAt;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* This invocation's page-cache summary, or null when no fetch went through the
|
|
166
|
+
* cache. 'hit' only when every fetch was one; 'bypass' when every fetch was
|
|
167
|
+
* one; otherwise 'miss'.
|
|
168
|
+
*
|
|
169
|
+
* @returns {{ state: 'hit'|'miss'|'bypass', fetchedAt: string|null, ageMs: number|null } | null}
|
|
170
|
+
*/
|
|
171
|
+
export function pageCacheSummary(now = Date.now()) {
|
|
172
|
+
const seen = requestContext.getStore()?.pageCache;
|
|
173
|
+
if (!seen) return null;
|
|
174
|
+
const state = seen.miss === 0 && seen.bypass === 0 ? 'hit'
|
|
175
|
+
: seen.hit === 0 && seen.miss === 0 ? 'bypass'
|
|
176
|
+
: 'miss';
|
|
177
|
+
return {
|
|
178
|
+
state,
|
|
179
|
+
fetchedAt: seen.fetchedAt === null ? null : new Date(seen.fetchedAt).toISOString(),
|
|
180
|
+
ageMs: seen.fetchedAt === null ? null : Math.max(0, now - seen.fetchedAt)
|
|
181
|
+
};
|
|
182
|
+
}
|
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.25.0
|
|
6
6
|
source: crawlforge-mcp-server
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: crawlforge-brand-style-guide
|
|
3
|
+
description: "Builds a brand style guide or a design-token file from a live website with the branding format of CrawlForge's scrape tool. Use when the user wants a company's brand colours, fonts, logo, button styles or design tokens, asks to extract a website's style, design system or brand kit, wants a style guide made from a URL, wants to match a site's look, or wants tokens for Figma, Tokens Studio or Style Dictionary (W3C DTCG JSON). One scrape call returns colour roles, the primary and secondary buttons and type read from the rendered page, the logo and favicons, font families and grouped CSS tokens, each value labelled with where it came from."
|
|
4
|
+
metadata:
|
|
5
|
+
version: 6.25.0
|
|
6
|
+
source: crawlforge-mcp-server
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# CrawlForge Brand Style Guide
|
|
10
|
+
|
|
11
|
+
Turn a website into a style guide or a design-token file. CrawlForge collects
|
|
12
|
+
the brand data in one `scrape` call; you write the guide from it.
|
|
13
|
+
|
|
14
|
+
## When to use
|
|
15
|
+
|
|
16
|
+
- "Make a style guide from stripe.com" / "build a brand kit for this site"
|
|
17
|
+
- "What colours and fonts does this company use?"
|
|
18
|
+
- "Grab this site's logo and button styles"
|
|
19
|
+
- "Match the look of this website"
|
|
20
|
+
- "Export this site's design tokens for Figma / Style Dictionary"
|
|
21
|
+
|
|
22
|
+
## The one call
|
|
23
|
+
|
|
24
|
+
```json
|
|
25
|
+
{
|
|
26
|
+
"tool": "scrape",
|
|
27
|
+
"params": {
|
|
28
|
+
"url": "https://stripe.com",
|
|
29
|
+
"formats": ["branding", "screenshot"],
|
|
30
|
+
"brandingOptions": { "dtcg": true }
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Cost: 2 credits. `branding` and `screenshot` share one browser render. Add
|
|
36
|
+
`"metadata"` if you need the site name and description too. Leave out `dtcg`
|
|
37
|
+
unless the user wants a token file: it can double the size of the result.
|
|
38
|
+
|
|
39
|
+
If the result is `truncated: true`, do not scrape again. Read the parts with
|
|
40
|
+
`read_result` and a `json_path`, such as `content.branding.roles` or
|
|
41
|
+
`content.branding.dtcg`.
|
|
42
|
+
|
|
43
|
+
## What comes back in `content.branding`
|
|
44
|
+
|
|
45
|
+
| Key | What it holds |
|
|
46
|
+
|-----|---------------|
|
|
47
|
+
| `colorScheme` | `light` or `dark`, from the page background |
|
|
48
|
+
| `roles` | `background`, `text`, `heading`, `link`, `primary`, `onPrimary`: each `{ value, source, evidence }` |
|
|
49
|
+
| `components` | `buttonPrimary` and `buttonSecondary`: fill, label colour, border, radius, padding, font, size and weight |
|
|
50
|
+
| `typography` | `body`, `h1`, `h2`: family, stack, size, weight, line height, letter spacing, colour |
|
|
51
|
+
| `logo` | `inlineHeaderSvg` (the header logo as SVG), `favicons`, `ogImage`, image `candidates` |
|
|
52
|
+
| `fonts`, `fontFaces` | font families ranked by use, and their `@font-face` sources |
|
|
53
|
+
| `tokens` | the site's CSS custom properties, grouped: `colors.ramps`, `colors.semantic`, `typography`, `spacing`, `radius`, `shadow`, plus `stats` on what was dropped |
|
|
54
|
+
| `colors` | every colour found, ranked by frequency |
|
|
55
|
+
| `dtcg` | with `dtcg: true`, the tokens and roles as W3C Design Tokens (2025.10) JSON |
|
|
56
|
+
| `provenance` | what each `source` value means |
|
|
57
|
+
|
|
58
|
+
### Reading `source`
|
|
59
|
+
|
|
60
|
+
- `computed`: read from an element on the rendered page. The `evidence` says
|
|
61
|
+
which, for example `button "Get started"`. Trust these first.
|
|
62
|
+
- `css-variable`, `css-var`, `declaration`, `theme-color`: found in the
|
|
63
|
+
site's CSS or HTML.
|
|
64
|
+
- `inferred`: a guess from CSS variable names, used when no element showed
|
|
65
|
+
the role. Say in the guide that the value is a guess, or confirm it on the
|
|
66
|
+
screenshot first.
|
|
67
|
+
|
|
68
|
+
`sources.rendered` tells you whether the page was rendered. When it is
|
|
69
|
+
`false`, `warnings` says why, and `components` and `typography` are empty.
|
|
70
|
+
|
|
71
|
+
## Writing the guide
|
|
72
|
+
|
|
73
|
+
Use the screenshot to check the roles before you write. Then cover:
|
|
74
|
+
|
|
75
|
+
1. **Logo**: the header SVG on light and dark grounds, plus the favicon and
|
|
76
|
+
share-image links.
|
|
77
|
+
2. **Colour roles**: one swatch per role, with its hex, its token name when a
|
|
78
|
+
ramp step matches, and what it is used for.
|
|
79
|
+
3. **Palette**: the ramps from `tokens.colors.ramps`, in step order.
|
|
80
|
+
4. **Typography**: the families, the type scale from `tokens.typography`, and
|
|
81
|
+
the body and heading styles from `typography`.
|
|
82
|
+
5. **Spacing and radius**, from `tokens.spacing` and `tokens.radius`.
|
|
83
|
+
6. **Buttons**: the primary and secondary buttons rebuilt from `components`.
|
|
84
|
+
7. **Sources**: label each value as read from the page, found in CSS, or
|
|
85
|
+
inferred, so a reader can check it.
|
|
86
|
+
|
|
87
|
+
For a page or artifact, show the swatches and buttons visually. For a token
|
|
88
|
+
file, save `dtcg` as `tokens.json`.
|
|
89
|
+
|
|
90
|
+
## Limits to tell the user about
|
|
91
|
+
|
|
92
|
+
- The guide is unofficial when the site is not the user's own. Say so on the
|
|
93
|
+
page, and point to the company's own brand resources for anything that will
|
|
94
|
+
ship.
|
|
95
|
+
- Many brand fonts are licensed. The `@font-face` URL shows which font a site
|
|
96
|
+
uses, not permission to use it, so render specimens in a fallback face.
|
|
97
|
+
- CSS holds no usage rules: no logo clear space, minimum sizes or do's and
|
|
98
|
+
don'ts. Those live in brand documents.
|
|
99
|
+
- `dtcg` follows the DTCG 2025.10 format, where colours and dimensions are
|
|
100
|
+
objects rather than strings. Support for 2025.10 varies by tool and version
|
|
101
|
+
(Style Dictionary lists it as in progress for v5), so tell the user to check
|
|
102
|
+
their tool before relying on the file.
|
|
103
|
+
- `buttonSecondary` is the outline button next to the primary. A site with no
|
|
104
|
+
partner button returns none.
|
|
105
|
+
- A blocked site comes back with a warning: retry with `escalate: true`. A
|
|
106
|
+
page that needed the stealth browser is not re-rendered for computed
|
|
107
|
+
styles, so its roles are `inferred`.
|
|
@@ -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.25.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.25.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.25.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.25.0
|
|
6
6
|
source: crawlforge-mcp-server
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -52,6 +52,7 @@ stored at `~/.crawlforge/config.json`.
|
|
|
52
52
|
| Watch a page for changes / monitor pricing | **crawlforge-change-tracking** |
|
|
53
53
|
| Scrape many URLs, run browser actions, generate llms.txt | **crawlforge-batch-automation** |
|
|
54
54
|
| Keep a browser open across calls: log in then read, click through a multi-step flow | **crawlforge-browser-sessions** |
|
|
55
|
+
| Make a style guide or design-token file from a website's brand | **crawlforge-brand-style-guide** |
|
|
55
56
|
|
|
56
57
|
## The 31 tools at a glance
|
|
57
58
|
|
|
@@ -108,3 +109,6 @@ get_batch_results...) up to 10+ for `deep_research`. Full table:
|
|
|
108
109
|
[credits](references/credits.md). Prefer the cheapest tool that does the job, and
|
|
109
110
|
cap dynamic tools (`deep_research`, `agent`, `crawl_deep`) with their max
|
|
110
111
|
parameters.
|
|
112
|
+
In a local session, asking again for the same page with different formats, paths or
|
|
113
|
+
selectors reuses this session's copy for 10 minutes at 1 credit; pass `maxAge: 0` to
|
|
114
|
+
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.25.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.25.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.25.0
|
|
6
6
|
source: crawlforge-mcp-server
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -10,6 +10,7 @@ CrawlForge credits.
|
|
|
10
10
|
| `url` | string (URL) | — | Required. |
|
|
11
11
|
| `formats` | array | `["markdown"]` | Any of `markdown`, `html`, `rawHtml`, `text`, `links`, `metadata`, `branding`, `screenshot`, `{type:"json", schema?, prompt?}`, `{type:"highlights", query, max_highlights?, mode?}` (the matching sentences, table rows and code blocks, verbatim, with offsets into the markdown; +1 credit once per call) or `{type:"question", question, mode?}` (an answer assembled from the evidence units; same +1). `mode:"model"` adds 3 credits and needs an LLM route. |
|
|
12
12
|
| `onlyMainContent` | boolean | `true` | Strip boilerplate via Readability. |
|
|
13
|
+
| `brandingOptions` | object | — | For `branding`: `computeStyles` (default `true`: render the page and read colour roles, the primary and secondary buttons and type from computed styles; `false` stays static and marks roles `inferred`), `rawVariables` (default `false`: add every CSS custom property as `cssVariables`), `dtcg` (default `false`: add the tokens as W3C Design Tokens 2025.10 JSON as `dtcg`, which can double the result; read it back with `read_result` and `json_path: "content.branding.dtcg"`), `fetchLinkedCss`, `maxStylesheets`. Every role and component carries `source` (`computed`, `css-variable`, `inferred`…); `provenance` in the result defines each. |
|
|
13
14
|
| `timeoutMs` | number | `15000` | 1000–60000. |
|
|
14
15
|
| `max_inline_chars` | number | `40000` | 1,000–10,000,000. A larger result returns `preview` + `result_handle` for `read_result` (below). |
|
|
15
16
|
|
|
@@ -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) {
|