crawlforge-mcp-server 6.23.2 → 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.
Files changed (31) hide show
  1. package/package.json +1 -1
  2. package/server.js +21 -11
  3. package/src/core/pageCache.js +249 -0
  4. package/src/schemas/toolOutputSchemas.js +17 -0
  5. package/src/server/inlineThreshold.js +12 -5
  6. package/src/server/requestContext.js +40 -0
  7. package/src/server/withAuth.js +84 -9
  8. package/src/skills/agent-skills/crawlforge-batch-automation/SKILL.md +1 -1
  9. package/src/skills/agent-skills/crawlforge-browser-sessions/SKILL.md +1 -1
  10. package/src/skills/agent-skills/crawlforge-change-tracking/SKILL.md +1 -1
  11. package/src/skills/agent-skills/crawlforge-deep-research/SKILL.md +1 -1
  12. package/src/skills/agent-skills/crawlforge-getting-started/SKILL.md +4 -1
  13. package/src/skills/agent-skills/crawlforge-getting-started/references/credits.md +4 -0
  14. package/src/skills/agent-skills/crawlforge-stealth-browsing/SKILL.md +1 -1
  15. package/src/skills/agent-skills/crawlforge-structured-extraction/SKILL.md +1 -1
  16. package/src/skills/agent-skills/crawlforge-web-scraping/SKILL.md +1 -1
  17. package/src/tools/basic/_fetch.js +35 -4
  18. package/src/tools/basic/extractLinks.js +3 -2
  19. package/src/tools/basic/extractMetadata.js +3 -2
  20. package/src/tools/basic/extractText.js +3 -2
  21. package/src/tools/basic/fetchUrl.js +3 -2
  22. package/src/tools/basic/scrapeStructured.js +3 -2
  23. package/src/tools/extract/_fetchAndParse.js +47 -18
  24. package/src/tools/extract/extractEmbeddedState.js +58 -7
  25. package/src/tools/extract/extractStructured.js +4 -1
  26. package/src/tools/extract/extractWithLlm.js +3 -0
  27. package/src/tools/scrape/unifiedScrape.js +4 -1
  28. package/src/utils/canonicalUrl.js +29 -0
  29. package/src/utils/fetchLadder.js +3 -3
  30. package/src/utils/maxAgeParam.js +18 -0
  31. package/src/utils/robotsGate.js +24 -5
@@ -21,7 +21,7 @@ import { z } from 'zod';
21
21
  import { fetchAndParse } from './_fetchAndParse.js';
22
22
  // Both live in crawlforge-extractors so the REST API's extract_embedded_state
23
23
  // runs this exact reader — one RSC flight-stream parser, not two.
24
- import { extractEmbeddedState, selectJsonPath, findJsonPaths } from 'crawlforge-extractors';
24
+ import { extractEmbeddedState, selectJsonPath, findJsonPaths, parseJsonPath } from 'crawlforge-extractors';
25
25
  import { stealthDocumentVerdict } from '../../utils/stealthVerdict.js';
26
26
  import { pageTitle } from '../../utils/pageTitle.js';
27
27
  import { setActualCost, markPreflightRefusal } from '../../server/requestContext.js';
@@ -38,6 +38,12 @@ const EMBEDDED_STATE_BASE_CREDITS = 2;
38
38
  // `find` ask for less up front.
39
39
  const LARGE_RESULT_BYTES = 256_000;
40
40
 
41
+ // A path miss carries the key tree where the path stopped, so the next path is
42
+ // aimed rather than guessed (repeat-call plan 2.1). Capped per object and in
43
+ // characters; over the character cap it drops to one level.
44
+ const MISS_TREE_KEYS = 30;
45
+ const MISS_TREE_CHARS = 2000;
46
+
41
47
  // A wall, as opposed to a missing page or a broken server (G4: a 404 or a 5xx
42
48
  // never escalates — see aBrowserMightPass).
43
49
  const WALL_STATUSES = new Set([403, 429, 444]);
@@ -75,24 +81,49 @@ function label(value) {
75
81
  * Shared shape with the REST route — keep the two in step.
76
82
  * @param {unknown} value
77
83
  * @param {number} depth
84
+ * @param {number} [maxKeys] per object; the rest are counted under "…"
78
85
  */
79
- export function describeKeys(value, depth) {
86
+ export function describeKeys(value, depth, maxKeys = Infinity) {
80
87
  if (Array.isArray(value)) {
81
88
  if (depth <= 1) return label(value);
82
89
  return value.length > 0
83
- ? { length: value.length, first: describeKeys(value[0], depth - 1) }
90
+ ? { length: value.length, first: describeKeys(value[0], depth - 1, maxKeys) }
84
91
  : { length: 0 };
85
92
  }
86
93
  if (value !== null && typeof value === 'object') {
87
94
  const keys = {};
88
- for (const [key, child] of Object.entries(value)) {
89
- keys[key] = depth > 1 ? describeKeys(child, depth - 1) : label(child);
95
+ const entries = Object.entries(value);
96
+ for (const [key, child] of entries.slice(0, maxKeys)) {
97
+ keys[key] = depth > 1 ? describeKeys(child, depth - 1, maxKeys) : label(child);
90
98
  }
99
+ if (entries.length > maxKeys) keys['…'] = `${entries.length - maxKeys} more keys`;
91
100
  return keys;
92
101
  }
93
102
  return label(value);
94
103
  }
95
104
 
105
+ /**
106
+ * The key tree where `path` stops resolving: `null` when the value there has
107
+ * no keys (the path ran into a string or number).
108
+ * @param {unknown} root
109
+ * @param {string} path
110
+ * @returns {{ at: string, tree: string } | null}
111
+ */
112
+ function missKeyTree(root, path) {
113
+ let current = root;
114
+ const walked = [];
115
+ for (const segment of parseJsonPath(path)) {
116
+ const key = Array.isArray(current) ? Number(segment) : segment;
117
+ if (current === null || typeof current !== 'object' || !Object.hasOwn(current, key)) break;
118
+ current = current[key];
119
+ walked.push(segment);
120
+ }
121
+ if (current === null || typeof current !== 'object') return null;
122
+ let tree = JSON.stringify(describeKeys(current, 2, MISS_TREE_KEYS));
123
+ if (tree.length > MISS_TREE_CHARS) tree = JSON.stringify(describeKeys(current, 1, MISS_TREE_KEYS));
124
+ return { at: walked.join('.'), tree };
125
+ }
126
+
96
127
  function httpError(status) {
97
128
  return new Error(`HTTP ${status}: ${STATUS_CODES[status] ?? ''}`.trimEnd());
98
129
  }
@@ -120,7 +151,7 @@ function failed(verdict, status, gotState) {
120
151
  */
121
152
  export function createExtractEmbeddedStateHandler({ escalateFetch } = {}) {
122
153
  return async function extractEmbeddedStateHandler({
123
- url, path, keys_only, find, raw = false, escalate, escalate_engine = 'auto', wait_for, user_agent, respect_robots
154
+ url, path, keys_only, find, raw = false, escalate, escalate_engine = 'auto', wait_for, user_agent, respect_robots, maxAge
124
155
  }) {
125
156
  // Each replaces `data` with something else, so asking for both has no
126
157
  // answer. Nothing was fetched, so - like a robots refusal - it costs nothing.
@@ -149,6 +180,7 @@ export function createExtractEmbeddedStateHandler({ escalateFetch } = {}) {
149
180
  const fetched = await fetchAndParse(url, {
150
181
  userAgent: user_agent,
151
182
  respectRobots: respect_robots,
183
+ maxAge,
152
184
  tool: 'extract_embedded_state',
153
185
  errorDocuments: true
154
186
  });
@@ -283,7 +315,26 @@ export function createExtractEmbeddedStateHandler({ escalateFetch } = {}) {
283
315
  }
284
316
  }
285
317
 
286
- const data = effectivePath ? selectJsonPath(state.data, effectivePath) : state.data;
318
+ let data = state.data;
319
+ if (effectivePath) {
320
+ try {
321
+ data = selectJsonPath(state.data, effectivePath);
322
+ } catch (error) {
323
+ // A path miss: the key tree where it stopped, and a next step that
324
+ // stays on this tool (the fallback hint would say to drop `path`).
325
+ reportCost();
326
+ const miss = missKeyTree(state.data, effectivePath);
327
+ return {
328
+ content: [{
329
+ type: 'text',
330
+ text: `Failed to extract embedded state: ${error.message}` +
331
+ (miss ? `\nKeys at ${miss.at ? `"${miss.at}"` : 'the top level'}: ${miss.tree}` : '') +
332
+ '\nNext step: Call again with a path built from the keys above, or with find:"<key>" to get the paths where a field of that name lives.'
333
+ }],
334
+ isError: true
335
+ };
336
+ }
337
+ }
287
338
  const bytes = Buffer.byteLength(JSON.stringify(data) ?? '');
288
339
 
289
340
  if (!path && !keys_only && find === undefined && bytes > LARGE_RESULT_BYTES) {
@@ -9,6 +9,7 @@ import { ElicitationHelper } from '../../core/ElicitationHelper.js'; // D1.4
9
9
  import { load } from 'cheerio';
10
10
  import { LLMManager } from '../../core/llm/LLMManager.js';
11
11
  import { CRAWLFORGE_USER_AGENT } from '../../utils/fetchIdentity.js';
12
+ import { MAX_AGE_PARAM } from '../../utils/maxAgeParam.js';
12
13
  import { fetchAndParse, flattenBodyText } from './_fetchAndParse.js';
13
14
  import { fetchErrorMessage } from '../../utils/fetchError.js';
14
15
  import { extractMainContent } from '../scrape/_mainContent.js';
@@ -267,6 +268,7 @@ const ExtractStructuredSchema = z.object({
267
268
  selectorHints: z.record(z.string()).optional(),
268
269
  respect_robots: z.boolean().optional(),
269
270
  user_agent: z.string().optional(),
271
+ ...MAX_AGE_PARAM,
270
272
  verify_numbers: z.boolean().optional().default(true)
271
273
  });
272
274
 
@@ -324,7 +326,7 @@ export class ExtractStructuredTool {
324
326
 
325
327
  try {
326
328
  const validated = ExtractStructuredSchema.parse(params);
327
- const { url, schema: requestedSchema, prompt, llmConfig, fallbackToSelectors, selectorHints, respect_robots, user_agent, verify_numbers } = validated;
329
+ const { url, schema: requestedSchema, prompt, llmConfig, fallbackToSelectors, selectorHints, respect_robots, user_agent, maxAge, verify_numbers } = validated;
328
330
  // `type` is optional on input, and the tool's own example omits it. The
329
331
  // validator reads a typeless schema as "anything", so the CSS fallback
330
332
  // reported valid:true for "2 reviews" in an integer field (R24). A schema
@@ -387,6 +389,7 @@ export class ExtractStructuredTool {
387
389
  const { html, $, textContent, warnings } = await fetchAndParse(url, {
388
390
  userAgent: user_agent || this.userAgent,
389
391
  respectRobots: respect_robots,
392
+ maxAge,
390
393
  tool: 'extract_structured'
391
394
  });
392
395
 
@@ -440,6 +440,7 @@ export class ExtractWithLlm {
440
440
  * @param {number} [params.maxTokens] - Max output tokens (default 4096)
441
441
  * @param {boolean} [params.respect_robots] - Per-request robots.txt override
442
442
  * @param {string} [params.user_agent] - Per-request identity override
443
+ * @param {number} [params.maxAge] - Page-cache age limit in ms, passed to fetchAndParse
443
444
  * @param {boolean} [params.verify_numbers] - Numeric provenance guard (default true)
444
445
  * @param {AbortSignal} [params.signal] - Aborts the model request(s); an aborted
445
446
  * call skips the retry and the sampling fallback
@@ -456,6 +457,7 @@ export class ExtractWithLlm {
456
457
  maxTokens = 4096,
457
458
  respect_robots,
458
459
  user_agent,
460
+ maxAge,
459
461
  verify_numbers = true,
460
462
  signal
461
463
  } = params;
@@ -501,6 +503,7 @@ export class ExtractWithLlm {
501
503
  const { html, textContent, warnings } = await fetchAndParse(url, {
502
504
  respectRobots: respect_robots,
503
505
  userAgent: user_agent,
506
+ maxAge,
504
507
  tool: 'extract_with_llm'
505
508
  });
506
509
  text = textContent;
@@ -33,6 +33,7 @@ import { htmlToMarkdown } from '../../utils/htmlToMarkdown.js';
33
33
  import { stripHiddenFromDom } from '../../utils/hiddenContent.js';
34
34
  import { extractBlockText } from '../basic/extractText.js';
35
35
  import { pageTitle } from '../../utils/pageTitle.js';
36
+ import { MAX_AGE_PARAM } from '../../utils/maxAgeParam.js';
36
37
  import { noteHostBlocked, clearHostBlocked, getHostBlock } from '../../utils/hostRateLimiter.js';
37
38
 
38
39
  // ── Schema ────────────────────────────────────────────────────────────────────
@@ -168,7 +169,8 @@ export const UnifiedScrapeSchema = z.object({
168
169
  // Compliance overrides, per request: identify as yourself for a target you
169
170
  // have your own agreement with, and take responsibility for ignoring robots.
170
171
  user_agent: z.string().optional(),
171
- respect_robots: z.boolean().optional()
172
+ respect_robots: z.boolean().optional(),
173
+ ...MAX_AGE_PARAM
172
174
  });
173
175
 
174
176
  // ── Helpers ───────────────────────────────────────────────────────────────────
@@ -432,6 +434,7 @@ export class UnifiedScrapeTool {
432
434
  timeoutMs,
433
435
  userAgent: validated.user_agent,
434
436
  respectRobots: validated.respect_robots,
437
+ maxAge: validated.maxAge,
435
438
  tool: 'scrape',
436
439
  stripTags: [], // we handle boilerplate ourselves
437
440
  // A real wall reaches a plain fetch as a 403 with the challenge in
@@ -0,0 +1,29 @@
1
+ /**
2
+ * canonicalUrl — one spelling per page, for keys that must match when two
3
+ * calls ask for the same page (the invocation log's urlKey now, the page cache
4
+ * of REPEAT_CALL_REDUCTION_PLAN Phase 1 next).
5
+ *
6
+ * Rules (plan 1.2): lowercase scheme and host, drop the default port (80 for
7
+ * http, 443 for https) and the #fragment, keep path and query string exactly
8
+ * as written. An empty path is the root path ("/"), which is what is requested.
9
+ * Anything that does not parse as a URL returns null.
10
+ */
11
+
12
+ export function canonicalUrl(input) {
13
+ if (typeof input !== 'string') return null;
14
+ let u;
15
+ try { u = new URL(input.trim()); } catch { return null; }
16
+
17
+ if (u.protocol !== 'http:' && u.protocol !== 'https:') {
18
+ u.hash = '';
19
+ return u.href;
20
+ }
21
+
22
+ // WHATWG URL already lowercases scheme and host and drops a default port,
23
+ // but it also rewrites the path (dot segments, percent-encoding), so the
24
+ // path and query are taken from the original string instead.
25
+ const rest = input.trim().match(/^[a-z][a-z0-9+.-]*:\/\/[^/?#]*([^#]*)/i)?.[1] ?? '';
26
+ const userinfo = u.username ? `${u.username}${u.password ? `:${u.password}` : ''}@` : '';
27
+ const pathAndQuery = rest === '' || rest.startsWith('?') ? `/${rest}` : rest;
28
+ return `${u.protocol}//${userinfo}${u.host}${pathAndQuery}`;
29
+ }
@@ -122,7 +122,7 @@ async function plainFetch(url, options) {
122
122
 
123
123
  /**
124
124
  * @param {string} url
125
- * @param {{ tool: 'extract_text'|'extract_links', userAgent?: string, respectRobots?: boolean,
125
+ * @param {{ tool: 'extract_text'|'extract_links', userAgent?: string, respectRobots?: boolean, maxAge?: number,
126
126
  * escalate?: boolean, escalateEngine?: string,
127
127
  * escalateFetch?: (args: { url: string, engine: string, respectRobots?: boolean }) => Promise<object> }} options
128
128
  * @returns {Promise<
@@ -132,10 +132,10 @@ async function plainFetch(url, options) {
132
132
  * `fields` carries `escalated` (only when escalate was asked for), `stealth`
133
133
  * (when the stage ran) and `rendered: false` (a client-rendered shell)
134
134
  */
135
- export async function fetchLadder(url, { tool, userAgent, respectRobots, escalate = false, escalateEngine = 'auto', escalateFetch } = {}) {
135
+ export async function fetchLadder(url, { tool, userAgent, respectRobots, maxAge, escalate = false, escalateEngine = 'auto', escalateFetch } = {}) {
136
136
  let escalationRan = false;
137
137
  try {
138
- const response = await plainFetch(url, { timeout: LADDER_TIMEOUT_MS, userAgent, respectRobots, tool });
138
+ const response = await plainFetch(url, { timeout: LADDER_TIMEOUT_MS, userAgent, respectRobots, maxAge, tool });
139
139
  const type = mediaType(response);
140
140
  if (response.ok && type && !TEXTUAL_TYPE.test(type)) return { unsupported: type };
141
141
 
@@ -0,0 +1,18 @@
1
+ /**
2
+ * maxAgeParam — the one declaration of `maxAge` (repeat-call reduction plan,
3
+ * Phase 1, 1.5 / D2). Passed through as `options.maxAge` to the two page-cache
4
+ * seams, fetchWithTimeout and fetchAndParse; undefined means the server's
5
+ * default TTL there.
6
+ */
7
+
8
+ import { z } from 'zod';
9
+ import { pageCacheActive } from '../core/pageCache.js';
10
+
11
+ export const MAX_AGE_PARAM = {
12
+ maxAge: z.number().int().min(0).optional().describe("Reuse this session's copy of the page if it was fetched within this many ms (default 600000 = 10 min); 0 always fetches fresh.")
13
+ };
14
+
15
+ // Phase 3 (3.1): the one sentence the nine cached tools' descriptions carry.
16
+ // Empty where the cache is off (hosted HTTP, CRAWLFORGE_PAGE_CACHE_MB=0).
17
+ export const PAGE_CACHE_TEXT = ' Asking again for the same page with different formats, paths or selectors reuses this session\'s copy for 10 minutes at 1 credit; pass maxAge: 0 to force a fresh fetch.';
18
+ export const PAGE_CACHE_SENTENCE = pageCacheActive() ? PAGE_CACHE_TEXT : '';
@@ -273,6 +273,29 @@ export function outboundHeaders(userAgent, signature = {}) {
273
273
  };
274
274
  }
275
275
 
276
+ /**
277
+ * The gate's decision, refusing as {@link preflightFetch} does, without the
278
+ * per-host throttle. A page-cache hit (src/core/pageCache.js) sends no
279
+ * request, so there is nothing to space, but the blocklist and robots.txt are
280
+ * asked again: either may have changed since the copy was fetched.
281
+ *
282
+ * @param {string} url
283
+ * @param {object} [options] see {@link robotsPreflight}
284
+ * @param {{ redirectedFrom?: string }} [reached] set when `url` is where a
285
+ * redirect from the requested URL led
286
+ * @returns {Promise<{ allowed: true, userAgent: string, crawlDelayMs: number,
287
+ * warnings: string[], overridden: boolean }>}
288
+ * @throws {BlockedHostError|RobotsDisallowedError}
289
+ */
290
+ export async function preflightDecision(url, options = {}, reached = {}) {
291
+ const decision = await robotsPreflight(url, options);
292
+ if (!decision.allowed) {
293
+ markPreflightRefusal('ROBOTS_DISALLOWED');
294
+ throw new RobotsDisallowedError(url, reached);
295
+ }
296
+ return decision;
297
+ }
298
+
276
299
  /**
277
300
  * The call-site helper: run the gate, honour Crawl-delay and any recorded
278
301
  * `Retry-After`, and hand back the identity headers to send and the gate for
@@ -286,11 +309,7 @@ export function outboundHeaders(userAgent, signature = {}) {
286
309
  * @throws {BlockedHostError|RobotsDisallowedError}
287
310
  */
288
311
  export async function preflightFetch(url, options = {}) {
289
- const decision = await robotsPreflight(url, options);
290
- if (!decision.allowed) {
291
- markPreflightRefusal('ROBOTS_DISALLOWED');
292
- throw new RobotsDisallowedError(url);
293
- }
312
+ const decision = await preflightDecision(url, options);
294
313
 
295
314
  await throttleHost(url, { crawlDelayMs: decision.crawlDelayMs });
296
315