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
@@ -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
- const { result: shaped, stored } = applyInlineThreshold(toolName, parsed, params, { store: getResultStore() });
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
- const spent = reported == null ? toolCost : Math.min(reported, toolCost);
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: 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 — skip injection silently
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.23.2
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.23.2
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.23.2
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.23.2
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.23.2
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.23.2
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.23.2
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.23.2
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 }} [options]
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 async function fetchWithTimeout(url, options = {}) {
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
- return Object.assign(response, {
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: Date.now() - startedAt,
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
  }