crawlforge-mcp-server 6.23.1 → 6.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/server.js +21 -11
- package/src/core/LLMsTxtAnalyzer.js +4 -1
- package/src/core/pageCache.js +249 -0
- package/src/schemas/toolOutputSchemas.js +18 -0
- package/src/server/inlineThreshold.js +12 -5
- package/src/server/requestContext.js +40 -0
- package/src/server/withAuth.js +84 -9
- package/src/skills/agent-skills/crawlforge-batch-automation/SKILL.md +1 -1
- package/src/skills/agent-skills/crawlforge-browser-sessions/SKILL.md +1 -1
- package/src/skills/agent-skills/crawlforge-change-tracking/SKILL.md +1 -1
- package/src/skills/agent-skills/crawlforge-deep-research/SKILL.md +1 -1
- package/src/skills/agent-skills/crawlforge-getting-started/SKILL.md +4 -1
- package/src/skills/agent-skills/crawlforge-getting-started/references/credits.md +4 -0
- package/src/skills/agent-skills/crawlforge-stealth-browsing/SKILL.md +1 -1
- package/src/skills/agent-skills/crawlforge-structured-extraction/SKILL.md +1 -1
- package/src/skills/agent-skills/crawlforge-web-scraping/SKILL.md +1 -1
- package/src/tools/basic/_fetch.js +35 -4
- package/src/tools/basic/extractLinks.js +3 -2
- package/src/tools/basic/extractMetadata.js +3 -2
- package/src/tools/basic/extractText.js +3 -2
- package/src/tools/basic/fetchUrl.js +3 -2
- package/src/tools/basic/scrapeStructured.js +3 -2
- package/src/tools/extract/_fetchAndParse.js +47 -18
- package/src/tools/extract/extractEmbeddedState.js +58 -7
- package/src/tools/extract/extractStructured.js +4 -1
- package/src/tools/extract/extractWithLlm.js +3 -0
- package/src/tools/extract/processDocument.js +12 -5
- package/src/tools/llmstxt/generateLLMsTxt.js +25 -14
- package/src/tools/scrape/unifiedScrape.js +4 -1
- package/src/utils/canonicalUrl.js +29 -0
- package/src/utils/fetchLadder.js +3 -3
- package/src/utils/maxAgeParam.js +18 -0
- package/src/utils/robotsGate.js +24 -5
- package/src/utils/ssrfGuard.js +2 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "crawlforge-mcp-server",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.24.0",
|
|
4
4
|
"mcpName": "io.github.mysleekdesigns/crawlforge-mcp-server",
|
|
5
5
|
"description": "CrawlForge MCP Server - Professional Model Context Protocol server with 31 web scraping, crawling, deep-research, and autonomous-extraction tools. Returns clean Markdown and structured JSON for Claude, Cursor, and any MCP client. Defaults to local Ollama for LLM extraction (no API key needed); OpenAI/Anthropic available as opt-in. Includes a unified multi-format scrape tool, an autonomous agent, pre-built site templates, and Camoufox stealth browsing.",
|
|
6
6
|
"main": "server.js",
|
package/server.js
CHANGED
|
@@ -60,6 +60,7 @@ import { scrapeStructuredHandler } from "./src/tools/basic/scrapeStructured.js";
|
|
|
60
60
|
import { createExtractEmbeddedStateHandler, EMBEDDED_STATE_INPUT_SHAPE } from "./src/tools/extract/extractEmbeddedState.js";
|
|
61
61
|
import { READ_RESULT_INPUT_SHAPE, readResultHandler } from "./src/tools/result/readResult.js"; // Phase 2
|
|
62
62
|
import { MAX_INLINE_CHARS_PARAM } from "./src/server/inlineThreshold.js"; // Phase 2
|
|
63
|
+
import { MAX_AGE_PARAM, PAGE_CACHE_SENTENCE } from "./src/utils/maxAgeParam.js"; // repeat-call reduction 1.5
|
|
63
64
|
import { REDACT_PII_PARAM } from "./src/server/redaction.js"; // Phase 5 (5.3)
|
|
64
65
|
import { SEARCH_QUERIES_PARAM, EXACTLY_ONE_QUERY_MESSAGE } from "./src/tools/search/batchSearch.js"; // Phase 5 (5.1)
|
|
65
66
|
import { markPreflightRefusal, preflightRefusal, internalOwnerToken } from "./src/server/requestContext.js";
|
|
@@ -113,7 +114,7 @@ if (configErrors.length > 0 && config.server.nodeEnv === 'production') {
|
|
|
113
114
|
// Create the server
|
|
114
115
|
const server = new McpServer({
|
|
115
116
|
name: "crawlforge",
|
|
116
|
-
version: "6.
|
|
117
|
+
version: "6.24.0",
|
|
117
118
|
description: "Production-ready MCP server with 31 web scraping, crawling, and content processing tools. Features MCP Resources (crawlforge://), Prompts, Sampling fallback, Elicitation, stealth browsing, stateful browser sessions with element refs, deep research, structured extraction, embedded JavaScript state extraction, real Google SERP rank tracking, Reddit search via community archives, change tracking, local-LLM extraction via Ollama, unified multi-format scrape, and autonomous agent tool.",
|
|
118
119
|
homepage: "https://www.crawlforge.dev",
|
|
119
120
|
icon: "https://www.crawlforge.dev/icon.png",
|
|
@@ -427,20 +428,21 @@ const refusedByGate = (result) => result?.success === false && preflightRefusal(
|
|
|
427
428
|
|
|
428
429
|
// Tool: fetch_url
|
|
429
430
|
registerToolIfEnabled("fetch_url", {
|
|
430
|
-
description: "Use this for a raw HTTP body - JSON, XML, plain text, an API response - or for the status code, headers and response time. Returns the body unprocessed. Not for HTML you intend to read: scrape returns markdown from one fetch, so fetch_url followed by extract_* is a double fetch. Not for JS-rendered or bot-protected pages (scrape, then stealth_mode). Supports custom headers (e.g. auth tokens) and a timeout; the result reports redirected and final_url. Cost: 1 credit. Example: fetch_url({url: \"https://api.example.com/v1/items\", timeout: 15000})",
|
|
431
|
+
description: "Use this for a raw HTTP body - JSON, XML, plain text, an API response - or for the status code, headers and response time. Returns the body unprocessed. Not for HTML you intend to read: scrape returns markdown from one fetch, so fetch_url followed by extract_* is a double fetch. Not for JS-rendered or bot-protected pages (scrape, then stealth_mode). Supports custom headers (e.g. auth tokens) and a timeout; the result reports redirected and final_url." + PAGE_CACHE_SENTENCE + " Cost: 1 credit. Example: fetch_url({url: \"https://api.example.com/v1/items\", timeout: 15000})",
|
|
431
432
|
annotations: { title: "Fetch URL", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
432
433
|
inputSchema: {
|
|
433
434
|
url: z.string().url().describe("The URL to fetch content from"),
|
|
434
435
|
headers: z.record(z.string()).optional().describe("Custom HTTP headers to include in the request"),
|
|
435
436
|
timeout: z.number().min(1000).max(30000).optional().default(10000).describe("Request timeout in milliseconds (1000-30000)"),
|
|
436
437
|
...COMPLIANCE_PARAMS,
|
|
438
|
+
...MAX_AGE_PARAM,
|
|
437
439
|
...MAX_INLINE_CHARS_PARAM
|
|
438
440
|
}
|
|
439
441
|
}, withAuth("fetch_url", fetchUrlHandler));
|
|
440
442
|
|
|
441
443
|
// Tool: extract_text
|
|
442
444
|
registerToolIfEnabled("extract_text", {
|
|
443
|
-
description: "Use this for a page's plain text or markdown with tags, scripts and styles removed - the cheapest read of a static HTML page. Use output_format:\"markdown\" for RAG; selector reads only the matched elements, max_length caps the returned text. Not for article pages (extract_content strips nav and boilerplate), JS-rendered pages (scrape), or when you also want links or metadata (scrape with several formats, one fetch). A PDF or other binary is refused (process_document reads documents); an empty client-rendered shell succeeds with rendered:false and a warning. Set escalate:true when the site is known to block: the plain fetch still runs first, and only if it is walled does the stealth browser re-read the page - projected at 6, charged 1 when the plain fetch worked. Cost: 1 credit. Example: extract_text({url: \"https://example.com/article\", output_format:\"markdown\"})",
|
|
445
|
+
description: "Use this for a page's plain text or markdown with tags, scripts and styles removed - the cheapest read of a static HTML page. Use output_format:\"markdown\" for RAG; selector reads only the matched elements, max_length caps the returned text. Not for article pages (extract_content strips nav and boilerplate), JS-rendered pages (scrape), or when you also want links or metadata (scrape with several formats, one fetch). A PDF or other binary is refused (process_document reads documents); an empty client-rendered shell succeeds with rendered:false and a warning. Set escalate:true when the site is known to block: the plain fetch still runs first, and only if it is walled does the stealth browser re-read the page - projected at 6, charged 1 when the plain fetch worked." + PAGE_CACHE_SENTENCE + " Cost: 1 credit. Example: extract_text({url: \"https://example.com/article\", output_format:\"markdown\"})",
|
|
444
446
|
annotations: { title: "Extract Text", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
445
447
|
inputSchema: {
|
|
446
448
|
url: z.string().url().describe("The URL to extract text from"),
|
|
@@ -451,6 +453,7 @@ registerToolIfEnabled("extract_text", {
|
|
|
451
453
|
max_length: z.number().int().min(1).max(1000000).optional().describe("Maximum characters of text or markdown to return; a longer result is cut, ends with \"...\" and carries truncated:true"),
|
|
452
454
|
...EXTRACT_ESCALATION_SHAPE,
|
|
453
455
|
...COMPLIANCE_PARAMS,
|
|
456
|
+
...MAX_AGE_PARAM,
|
|
454
457
|
...REDACT_PII_PARAM
|
|
455
458
|
}
|
|
456
459
|
}, withAuth("extract_text", createExtractTextHandler({
|
|
@@ -459,7 +462,7 @@ registerToolIfEnabled("extract_text", {
|
|
|
459
462
|
|
|
460
463
|
// Tool: extract_links
|
|
461
464
|
registerToolIfEnabled("extract_links", {
|
|
462
|
-
description: "Use this to list the hyperlinks on one page - a crawl seed list, a broken-link audit, related resources. filter_external:true drops internal links; mailto:, tel: and javascript: links are type \"other\". Not for a whole site (map_site), and not alongside a scrape of the same URL: scrape formats:[\"markdown\",\"links\"] returns both in one fetch. A PDF or other binary is refused (process_document reads documents); an empty client-rendered shell succeeds with rendered:false and a warning. Set escalate:true when the site is known to block: the plain fetch still runs first, and only if it is walled does the stealth browser re-read the page - projected at 6, charged 1 when the plain fetch worked. Cost: 1 credit. Example: extract_links({url: \"https://example.com\", filter_external: true})",
|
|
465
|
+
description: "Use this to list the hyperlinks on one page - a crawl seed list, a broken-link audit, related resources. filter_external:true drops internal links; mailto:, tel: and javascript: links are type \"other\". Not for a whole site (map_site), and not alongside a scrape of the same URL: scrape formats:[\"markdown\",\"links\"] returns both in one fetch. A PDF or other binary is refused (process_document reads documents); an empty client-rendered shell succeeds with rendered:false and a warning. Set escalate:true when the site is known to block: the plain fetch still runs first, and only if it is walled does the stealth browser re-read the page - projected at 6, charged 1 when the plain fetch worked." + PAGE_CACHE_SENTENCE + " Cost: 1 credit. Example: extract_links({url: \"https://example.com\", filter_external: true})",
|
|
463
466
|
annotations: { title: "Extract Links", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
464
467
|
inputSchema: {
|
|
465
468
|
url: z.string().url().describe("The URL to extract links from"),
|
|
@@ -467,6 +470,7 @@ registerToolIfEnabled("extract_links", {
|
|
|
467
470
|
base_url: z.string().url().optional().describe("Base URL for resolving relative links"),
|
|
468
471
|
...EXTRACT_ESCALATION_SHAPE,
|
|
469
472
|
...COMPLIANCE_PARAMS,
|
|
473
|
+
...MAX_AGE_PARAM,
|
|
470
474
|
...MAX_INLINE_CHARS_PARAM
|
|
471
475
|
}
|
|
472
476
|
}, withAuth("extract_links", createExtractLinksHandler({
|
|
@@ -475,23 +479,25 @@ registerToolIfEnabled("extract_links", {
|
|
|
475
479
|
|
|
476
480
|
// Tool: extract_metadata
|
|
477
481
|
registerToolIfEnabled("extract_metadata", {
|
|
478
|
-
description: "Use this for a page's SEO metadata only: title, meta description, Open Graph tags, canonical URL, schema.org data. `url` is the final URL; redirected:true (with requested_url) says a redirect moved the request to another page. Not alongside a scrape of the same URL: scrape formats:[\"markdown\",\"metadata\"] returns both in one fetch. Cost: 1 credit. Example: extract_metadata({url: \"https://example.com\"})",
|
|
482
|
+
description: "Use this for a page's SEO metadata only: title, meta description, Open Graph tags, canonical URL, schema.org data. `url` is the final URL; redirected:true (with requested_url) says a redirect moved the request to another page. Not alongside a scrape of the same URL: scrape formats:[\"markdown\",\"metadata\"] returns both in one fetch." + PAGE_CACHE_SENTENCE + " Cost: 1 credit. Example: extract_metadata({url: \"https://example.com\"})",
|
|
479
483
|
annotations: { title: "Extract Metadata", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
480
484
|
inputSchema: {
|
|
481
485
|
url: z.string().url().describe("The URL to extract metadata from"),
|
|
482
486
|
json_ld_types: z.array(z.string()).optional().describe("Filter the returned JSON-LD to nodes of these schema.org types, e.g. [\"Product\",\"Offer\"]. Subtypes match their parent: \"Event\" returns MusicEvent, \"Offer\" returns AggregateOffer, \"ItemList\" returns BreadcrumbList. Nodes are found at any depth, including inside @graph and nested inside a parent node; a match nested inside another returned node comes back inside it, not again on its own. When set, json_ld carries only the matching nodes instead of the raw dump, and json_ld_type_counts reports how many matched per requested type, nested ones included. Documented types: ItemList, Product, Offer, Event, JobPosting, RealEstateListing — any other schema.org type is matched exactly."),
|
|
483
487
|
...COMPLIANCE_PARAMS,
|
|
488
|
+
...MAX_AGE_PARAM,
|
|
484
489
|
...MAX_INLINE_CHARS_PARAM
|
|
485
490
|
}
|
|
486
491
|
}, withAuth("extract_metadata", extractMetadataHandler));
|
|
487
492
|
|
|
488
493
|
// Tool: extract_embedded_state
|
|
489
494
|
registerToolIfEnabled("extract_embedded_state", {
|
|
490
|
-
description: "Use this when a page's data lives in its embedded JavaScript state rather than its rendered HTML - Next.js (__NEXT_DATA__, and React Server Component payloads with references between rows resolved plus a data_rows index of the rows carrying data), Nuxt (Nuxt 3's __NUXT_DATA__ decoded), SvelteKit, Apollo, Redux (__INITIAL_STATE__, __PRELOADED_STATE__), ytInitialData and ytInitialPlayerResponse, Inertia, Shopify, JSON data-* attributes and <script type=\"application/json\"> blocks. One fetch, exact values, no LLM in the extraction path, so nothing can be fabricated. Payloads are routinely over a megabyte - pass `path` to return one subtree, keys_only:true to see the first two levels of keys before choosing one, or find:\"<key>\" to get every path where a field of that name lives, each ready to pass back as `path`. A result over max_inline_chars comes back as a preview (whole lines of the JSON), data_keys and a result_handle; read the rest with read_result json_path, e.g. \"data.next_data.props\". Set escalate:true when the site is known to block: the plain fetch still runs first, and only if it is walled does the stealth browser re-read the page, run the same parser and also read the globals off window (window_state: __NEXT_DATA__, __NUXT__, ytInitialData and others, read after JavaScript ran) - projected at 7, charged 2 when the plain fetch worked. Not for the rendered text of a page (scrape) or for sites built without a framework payload. Cost: 2 credits. Example: extract_embedded_state({url: \"https://www.ticketmaster.com/discover/concerts\", path: \"next_data.props.pageProps\"})",
|
|
495
|
+
description: "Use this when a page's data lives in its embedded JavaScript state rather than its rendered HTML - Next.js (__NEXT_DATA__, and React Server Component payloads with references between rows resolved plus a data_rows index of the rows carrying data), Nuxt (Nuxt 3's __NUXT_DATA__ decoded), SvelteKit, Apollo, Redux (__INITIAL_STATE__, __PRELOADED_STATE__), ytInitialData and ytInitialPlayerResponse, Inertia, Shopify, JSON data-* attributes and <script type=\"application/json\"> blocks. One fetch, exact values, no LLM in the extraction path, so nothing can be fabricated. Payloads are routinely over a megabyte - pass `path` to return one subtree, keys_only:true to see the first two levels of keys before choosing one, or find:\"<key>\" to get every path where a field of that name lives, each ready to pass back as `path`. A result over max_inline_chars comes back as a preview (whole lines of the JSON), data_keys and a result_handle; read the rest with read_result json_path, e.g. \"data.next_data.props\". Set escalate:true when the site is known to block: the plain fetch still runs first, and only if it is walled does the stealth browser re-read the page, run the same parser and also read the globals off window (window_state: __NEXT_DATA__, __NUXT__, ytInitialData and others, read after JavaScript ran) - projected at 7, charged 2 when the plain fetch worked. Not for the rendered text of a page (scrape) or for sites built without a framework payload." + PAGE_CACHE_SENTENCE + " Cost: 2 credits. Example: extract_embedded_state({url: \"https://www.ticketmaster.com/discover/concerts\", path: \"next_data.props.pageProps\"})",
|
|
491
496
|
annotations: { title: "Extract Embedded State", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
492
497
|
inputSchema: {
|
|
493
498
|
...EMBEDDED_STATE_INPUT_SHAPE,
|
|
494
499
|
...COMPLIANCE_PARAMS,
|
|
500
|
+
...MAX_AGE_PARAM,
|
|
495
501
|
...MAX_INLINE_CHARS_PARAM
|
|
496
502
|
}
|
|
497
503
|
}, withAuth("extract_embedded_state", createExtractEmbeddedStateHandler({
|
|
@@ -500,7 +506,7 @@ registerToolIfEnabled("extract_embedded_state", {
|
|
|
500
506
|
|
|
501
507
|
// Tool: scrape_structured
|
|
502
508
|
registerToolIfEnabled("scrape_structured", {
|
|
503
|
-
description: "Use this when you know the exact CSS selectors for the data you want - e.g. a pricing table or product list with consistent markup. More reliable than LLM extraction for well-structured pages. By default each selector is matched independently across the whole page, so the returned arrays are NOT row-aligned: data.price[0] need not belong to the same row as data.name[0]. Pass row_selector to get aligned records instead - one object per row, null for a field the row lacks. Not for pages whose markup varies or where you cannot name the selectors (extract_structured, LLM-driven). Cost: 2 credits. Example: scrape_structured({url: \"https://shop.com/products\", row_selector: \".product-card\", selectors: {price: \".price\", name: \".product-title\"}})",
|
|
509
|
+
description: "Use this when you know the exact CSS selectors for the data you want - e.g. a pricing table or product list with consistent markup. More reliable than LLM extraction for well-structured pages. By default each selector is matched independently across the whole page, so the returned arrays are NOT row-aligned: data.price[0] need not belong to the same row as data.name[0]. Pass row_selector to get aligned records instead - one object per row, null for a field the row lacks. Not for pages whose markup varies or where you cannot name the selectors (extract_structured, LLM-driven)." + PAGE_CACHE_SENTENCE + " Cost: 2 credits. Example: scrape_structured({url: \"https://shop.com/products\", row_selector: \".product-card\", selectors: {price: \".price\", name: \".product-title\"}})",
|
|
504
510
|
annotations: { title: "Scrape Structured Data", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
505
511
|
inputSchema: {
|
|
506
512
|
url: z.string().url().describe("The URL to scrape"),
|
|
@@ -510,7 +516,8 @@ registerToolIfEnabled("scrape_structured", {
|
|
|
510
516
|
])).describe("CSS selectors mapping field names to selectors. Append @attr to extract an attribute instead of text (e.g. \"a.link@href\", \"img@src\"); @href and @src values are resolved to absolute URLs. A field may also be {selector, multiple: true} to return every match as an array - inside a row_selector row a field otherwise keeps only the first match"),
|
|
511
517
|
row_selector: z.string().optional().describe("CSS selector for the repeating row/container element. When set, each field in selectors is matched inside each row and data is an array of row-aligned records ({field: value|null}) instead of parallel arrays"),
|
|
512
518
|
max_results: z.number().int().min(1).optional().describe("Maximum number of matches to return per field when a selector matches multiple elements, or the maximum number of rows when row_selector is set"),
|
|
513
|
-
...COMPLIANCE_PARAMS
|
|
519
|
+
...COMPLIANCE_PARAMS,
|
|
520
|
+
...MAX_AGE_PARAM
|
|
514
521
|
}
|
|
515
522
|
}, withAuth("scrape_structured", scrapeStructuredHandler));
|
|
516
523
|
|
|
@@ -823,7 +830,7 @@ registerToolIfEnabled("analyze_content", {
|
|
|
823
830
|
|
|
824
831
|
// Tool: extract_structured
|
|
825
832
|
registerToolIfEnabled("extract_structured", {
|
|
826
|
-
description: "Use this to get a specific data shape from a page using a JSON schema when you can describe the fields but not their selectors - product details, job listings, event data. Uses an LLM by default; falls back to CSS selectors when no LLM is configured. Not for pages with stable markup whose selectors you know (scrape_structured, no LLM, cheaper). Cost: 3 credits. Example: extract_structured({url: \"https://jobs.example.com/post/123\", schema: {properties: {title: {type:\"string\"}, salary: {type:\"string\"}}, required:[\"title\"]}})",
|
|
833
|
+
description: "Use this to get a specific data shape from a page using a JSON schema when you can describe the fields but not their selectors - product details, job listings, event data. Uses an LLM by default; falls back to CSS selectors when no LLM is configured. Not for pages with stable markup whose selectors you know (scrape_structured, no LLM, cheaper)." + PAGE_CACHE_SENTENCE + " Cost: 3 credits. Example: extract_structured({url: \"https://jobs.example.com/post/123\", schema: {properties: {title: {type:\"string\"}, salary: {type:\"string\"}}, required:[\"title\"]}})",
|
|
827
834
|
annotations: { title: "Extract Structured Data", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
828
835
|
inputSchema: {
|
|
829
836
|
url: z.string().url().describe("The URL to extract structured data from"),
|
|
@@ -840,6 +847,7 @@ registerToolIfEnabled("extract_structured", {
|
|
|
840
847
|
fallbackToSelectors: z.boolean().optional().default(true).describe("Fall back to CSS selector extraction if LLM is unavailable"),
|
|
841
848
|
selectorHints: z.record(z.string()).optional().describe("CSS selector hints to guide extraction"),
|
|
842
849
|
...COMPLIANCE_PARAMS,
|
|
850
|
+
...MAX_AGE_PARAM,
|
|
843
851
|
...VERIFY_NUMBERS_PARAM
|
|
844
852
|
},
|
|
845
853
|
outputSchema: OUTPUT_SCHEMAS.extract_structured
|
|
@@ -858,7 +866,7 @@ registerToolIfEnabled("extract_structured", {
|
|
|
858
866
|
|
|
859
867
|
// Tool: extract_with_llm
|
|
860
868
|
registerToolIfEnabled("extract_with_llm", {
|
|
861
|
-
description: "Extract data from a URL or text using a natural-language prompt. Defaults to a local Ollama model (http://localhost:11434, no API key required) and picks an installed model itself; pass `model` to choose one, or provider: \"openai\" or \"anthropic\" with the matching API key to use a cloud model instead. Not for known selectors (scrape_structured) or a schema-shaped result (extract_structured). Call list_ollama_models only when a model name is rejected. Cost: 3 credits plus your LLM provider's own charge.",
|
|
869
|
+
description: "Extract data from a URL or text using a natural-language prompt. Defaults to a local Ollama model (http://localhost:11434, no API key required) and picks an installed model itself; pass `model` to choose one, or provider: \"openai\" or \"anthropic\" with the matching API key to use a cloud model instead. Not for known selectors (scrape_structured) or a schema-shaped result (extract_structured). Call list_ollama_models only when a model name is rejected." + PAGE_CACHE_SENTENCE + " Cost: 3 credits plus your LLM provider's own charge.",
|
|
862
870
|
annotations: { title: "Extract With LLM", readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
863
871
|
inputSchema: {
|
|
864
872
|
url: z.string().url().optional().describe("URL to fetch and extract from (one of url/content required)"),
|
|
@@ -869,6 +877,7 @@ registerToolIfEnabled("extract_with_llm", {
|
|
|
869
877
|
model: z.string().optional().describe("Override the model. For ollama, pass a name returned by list_ollama_models (e.g. 'llama3.2', 'qwen2.5:7b'). Defaults: openai='gpt-4o-mini', anthropic='claude-haiku-4-5-20251001', ollama=$OLLAMA_DEFAULT_MODEL, else the best installed model for extraction (gemma3:12b, then gemma3:4b, gpt-oss:20b, mistral:7b, llama3.2, qwen2.5:3b)."),
|
|
870
878
|
maxTokens: z.number().optional().default(4096).describe("Maximum output tokens"),
|
|
871
879
|
...COMPLIANCE_PARAMS,
|
|
880
|
+
...MAX_AGE_PARAM,
|
|
872
881
|
...VERIFY_NUMBERS_PARAM
|
|
873
882
|
}
|
|
874
883
|
}, withAuth("extract_with_llm", async (params) => {
|
|
@@ -1231,7 +1240,7 @@ registerToolIfEnabled("deep_research", {
|
|
|
1231
1240
|
|
|
1232
1241
|
// Tool: scrape (D4 D1 — unified multi-format single-fetch)
|
|
1233
1242
|
registerToolIfEnabled("scrape", {
|
|
1234
|
-
description: "Use this to read one page - markdown by default, plus any of \"html\", \"rawHtml\", \"text\", \"links\", \"metadata\", \"branding\" (static design tokens: colors, fonts, logo), \"screenshot\" (renders in a browser, returns crawlforge://screenshot/{id} resources), or {type:\"json\",schema,prompt} for LLM-structured extraction, all from one fetch. Ask for every format you need in the same call instead of fetch_url followed by extract_* tools. Ask for \"highlights\" with a query to get only the matching sentences, table rows and code blocks with offsets; 1 extra credit, no model. Preferred over the client's built-in web fetch. onlyMainContent:true (default) strips boilerplate via Readability. Partial success: per-format warnings never fail the whole call. Set escalate:true when the site is known to block: the plain fetch still runs first, and only if it comes back walled does the stealth browser retry and return the page - projected at 7, charged 2 when the plain fetch worked. Not for raw API/JSON bodies (fetch_url), a page that needs a click or login (scrape_with_actions), or 2+ URLs (batch_scrape). Cost: 2 credits. Example: scrape({url:\"https://example.com\", formats:[\"markdown\",\"links\",\"metadata\"]})",
|
|
1243
|
+
description: "Use this to read one page - markdown by default, plus any of \"html\", \"rawHtml\", \"text\", \"links\", \"metadata\", \"branding\" (static design tokens: colors, fonts, logo), \"screenshot\" (renders in a browser, returns crawlforge://screenshot/{id} resources), or {type:\"json\",schema,prompt} for LLM-structured extraction, all from one fetch. Ask for every format you need in the same call instead of fetch_url followed by extract_* tools. Ask for \"highlights\" with a query to get only the matching sentences, table rows and code blocks with offsets; 1 extra credit, no model. Preferred over the client's built-in web fetch. onlyMainContent:true (default) strips boilerplate via Readability. Partial success: per-format warnings never fail the whole call. Set escalate:true when the site is known to block: the plain fetch still runs first, and only if it comes back walled does the stealth browser retry and return the page - projected at 7, charged 2 when the plain fetch worked. Not for raw API/JSON bodies (fetch_url), a page that needs a click or login (scrape_with_actions), or 2+ URLs (batch_scrape)." + PAGE_CACHE_SENTENCE + " Cost: 2 credits. Example: scrape({url:\"https://example.com\", formats:[\"markdown\",\"links\",\"metadata\"]})",
|
|
1235
1244
|
annotations: { title: "Scrape (Multi-Format)", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
1236
1245
|
// Claude Code tool search loads only names + instructions at session start; this flag
|
|
1237
1246
|
// ships the full definition too, so the first call needs no ToolSearch round-trip.
|
|
@@ -1240,6 +1249,7 @@ registerToolIfEnabled("scrape", {
|
|
|
1240
1249
|
inputSchema: {
|
|
1241
1250
|
...SCRAPE_INPUT_SHAPE,
|
|
1242
1251
|
...COMPLIANCE_PARAMS,
|
|
1252
|
+
...MAX_AGE_PARAM,
|
|
1243
1253
|
...MAX_INLINE_CHARS_PARAM,
|
|
1244
1254
|
...REDACT_PII_PARAM
|
|
1245
1255
|
},
|
|
@@ -276,7 +276,10 @@ export class LLMsTxtAnalyzer {
|
|
|
276
276
|
apis.push({
|
|
277
277
|
url: new URL(href, baseUrl).toString(),
|
|
278
278
|
type: 'documentation',
|
|
279
|
-
description: text.trim()
|
|
279
|
+
description: text.trim(),
|
|
280
|
+
// Link text as written, the label when the page itself was not
|
|
281
|
+
// classified (live sweep 7.4: it was labelled by its slug).
|
|
282
|
+
title: $(element).text().replace(/\s+/g, ' ').trim()
|
|
280
283
|
});
|
|
281
284
|
}
|
|
282
285
|
});
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pageCache — one copy of each fetched page per process, shared by the two
|
|
3
|
+
* fetch seams (REPEAT_CALL_REDUCTION_PLAN Phase 1).
|
|
4
|
+
*
|
|
5
|
+
* fetchWithTimeout (fetch_url, extract_metadata, scrape_structured,
|
|
6
|
+
* extract_text, extract_links) and fetchAndParse (scrape,
|
|
7
|
+
* extract_embedded_state, extract_structured, extract_with_llm, agent) both go
|
|
8
|
+
* through throughPageCache, so asking for the same page again with other
|
|
9
|
+
* formats, paths or selectors reuses the first fetch. What is kept is the
|
|
10
|
+
* response (status, final URL, headers, body), never a tool's output.
|
|
11
|
+
*
|
|
12
|
+
* Bounded by bytes, least recently used first out (CRAWLFORGE_PAGE_CACHE_MB,
|
|
13
|
+
* default 64; 0 turns it off). Memory only. A copy is served while it is
|
|
14
|
+
* younger than `maxAge` (default 10 min, D2); `maxAge: 0` fetches fresh and
|
|
15
|
+
* stores the result, `noCache: true` neither reads nor writes.
|
|
16
|
+
*
|
|
17
|
+
* Off on the hosted HTTP server and for internal (REST-proxy) requests until
|
|
18
|
+
* tenant isolation lands (D3), and off under NODE_ENV=test unless a test turns
|
|
19
|
+
* it on with _resetPageCache({ enabled: true }) (likewise inside node --test).
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { createHash } from 'node:crypto';
|
|
23
|
+
import { canonicalUrl } from '../utils/canonicalUrl.js';
|
|
24
|
+
import { config } from '../constants/config.js';
|
|
25
|
+
import { preflightDecision } from '../utils/robotsGate.js';
|
|
26
|
+
import { plainVerdict } from '../tools/basic/_targetFailure.js';
|
|
27
|
+
import { isInternalRequest, recordPageCache } from '../server/requestContext.js';
|
|
28
|
+
|
|
29
|
+
export const DEFAULT_MAX_AGE_MS = 10 * 60 * 1000;
|
|
30
|
+
const DEFAULT_CACHE_MB = 64;
|
|
31
|
+
// A bot wall is tens of KB. Larger bodies skip the verdict's second parse.
|
|
32
|
+
const VERDICT_MAX_BYTES = 1024 * 1024;
|
|
33
|
+
// Requests that carry a credential are never served from or stored in the cache.
|
|
34
|
+
const CREDENTIAL_HEADERS = new Set(['authorization', 'proxy-authorization', 'cookie']);
|
|
35
|
+
|
|
36
|
+
/** key -> entry, least recently used first */
|
|
37
|
+
const entries = new Map();
|
|
38
|
+
/** in-flight key -> Promise<{ result, page, entry }> */
|
|
39
|
+
const inflight = new Map();
|
|
40
|
+
let totalBytes = 0;
|
|
41
|
+
let testOverride = null;
|
|
42
|
+
|
|
43
|
+
function maxBytes() {
|
|
44
|
+
if (testOverride?.maxBytes != null) return testOverride.maxBytes;
|
|
45
|
+
const raw = process.env.CRAWLFORGE_PAGE_CACHE_MB;
|
|
46
|
+
const mb = raw === undefined || raw === '' ? DEFAULT_CACHE_MB : Number(raw);
|
|
47
|
+
return (Number.isFinite(mb) && mb >= 0 ? mb : DEFAULT_CACHE_MB) * 1024 * 1024;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Whether this process and this request may use the cache at all. */
|
|
51
|
+
export function pageCacheActive() {
|
|
52
|
+
// node --test marks its test processes with NODE_TEST_CONTEXT.
|
|
53
|
+
const underTest = process.env.NODE_ENV === 'test' || process.env.NODE_TEST_CONTEXT !== undefined;
|
|
54
|
+
if (underTest && !testOverride?.enabled) return false;
|
|
55
|
+
if (process.argv.includes('--http') || process.env.MCP_HTTP === 'true') return false;
|
|
56
|
+
if (isInternalRequest()) return false;
|
|
57
|
+
return maxBytes() > 0;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The fetch, canonicalised: URL, method, robots setting, identity, the
|
|
62
|
+
* caller's headers and API key, and which seam asked (the two send different
|
|
63
|
+
* Accept headers). Hashed, so no header value or key is kept in memory as a
|
|
64
|
+
* key. Null when the request must not touch the cache.
|
|
65
|
+
*/
|
|
66
|
+
export function pageCacheKey(url, options, variant) {
|
|
67
|
+
const canonical = canonicalUrl(url);
|
|
68
|
+
if (!canonical) return null;
|
|
69
|
+
if (options.method && String(options.method).toUpperCase() !== 'GET') return null;
|
|
70
|
+
const headers = Object.entries(options.headers || {})
|
|
71
|
+
.map(([name, value]) => [name.toLowerCase(), String(value)])
|
|
72
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
|
73
|
+
if (headers.some(([name]) => CREDENTIAL_HEADERS.has(name))) return null;
|
|
74
|
+
const respect = options.respectRobots === undefined
|
|
75
|
+
? config.crawling.respectRobots
|
|
76
|
+
: options.respectRobots !== false;
|
|
77
|
+
const userAgent = typeof options.userAgent === 'string' ? options.userAgent.trim() || null : null;
|
|
78
|
+
return createHash('sha256')
|
|
79
|
+
.update(JSON.stringify([variant, 'GET', canonical, respect, userAgent, headers, options.apiKey ?? null]))
|
|
80
|
+
.digest('hex');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The status, final URL, headers and body of a fetched response, as stored. */
|
|
84
|
+
export function pageFromResponse(response, body) {
|
|
85
|
+
const headers = [];
|
|
86
|
+
if (typeof response.headers?.forEach === 'function') {
|
|
87
|
+
response.headers.forEach((value, name) => headers.push([name.toLowerCase(), value]));
|
|
88
|
+
} else {
|
|
89
|
+
// A headers object with only get() (test doubles): keep what the seams read.
|
|
90
|
+
for (const name of ['content-type', 'cache-control']) {
|
|
91
|
+
const value = response.headers?.get?.(name);
|
|
92
|
+
if (value != null) headers.push([name, value]);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return {
|
|
96
|
+
status: response.status,
|
|
97
|
+
statusText: response.statusText ?? '',
|
|
98
|
+
url: response.url,
|
|
99
|
+
redirected: response.redirected === true,
|
|
100
|
+
headers,
|
|
101
|
+
body,
|
|
102
|
+
fetchedAt: Date.now()
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function pageHeader(page, name) {
|
|
107
|
+
return page.headers.find(([key]) => key === name)?.[1] ?? null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** 2xx, storable, and not a bot wall (the same verdict extract_text applies). */
|
|
111
|
+
function cacheable(page) {
|
|
112
|
+
if (!(page.status >= 200 && page.status <= 299) || typeof page.body !== 'string') return false;
|
|
113
|
+
if (/\bno-store\b/i.test(pageHeader(page, 'cache-control') ?? '')) return false;
|
|
114
|
+
if (Buffer.byteLength(page.body) <= VERDICT_MAX_BYTES) {
|
|
115
|
+
const verdict = plainVerdict(
|
|
116
|
+
{ status: page.status, url: page.url, _body: page.body, headers: new Headers(page.headers) },
|
|
117
|
+
page.url
|
|
118
|
+
);
|
|
119
|
+
if (verdict?.blocked) return false;
|
|
120
|
+
}
|
|
121
|
+
return true;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function store(key, page) {
|
|
125
|
+
if (!cacheable(page)) return null;
|
|
126
|
+
const entry = {
|
|
127
|
+
status: page.status,
|
|
128
|
+
statusText: page.statusText,
|
|
129
|
+
url: page.url,
|
|
130
|
+
redirected: page.redirected,
|
|
131
|
+
headers: page.headers,
|
|
132
|
+
body: page.body,
|
|
133
|
+
responseTime: page.responseTime,
|
|
134
|
+
fetchedAt: page.fetchedAt
|
|
135
|
+
};
|
|
136
|
+
entry.bytes = Buffer.byteLength(entry.body) + Buffer.byteLength(JSON.stringify(entry.headers)) + (entry.url?.length ?? 0);
|
|
137
|
+
const cap = maxBytes();
|
|
138
|
+
if (entry.bytes > cap) return null;
|
|
139
|
+
drop(key);
|
|
140
|
+
entries.set(key, entry);
|
|
141
|
+
totalBytes += entry.bytes;
|
|
142
|
+
for (const [oldKey] of entries) {
|
|
143
|
+
if (totalBytes <= cap) break;
|
|
144
|
+
drop(oldKey);
|
|
145
|
+
}
|
|
146
|
+
return entry;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function drop(key) {
|
|
150
|
+
const entry = entries.get(key);
|
|
151
|
+
if (!entry) return;
|
|
152
|
+
entries.delete(key);
|
|
153
|
+
totalBytes -= entry.bytes;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function lookup(key, maxAge) {
|
|
157
|
+
const entry = entries.get(key);
|
|
158
|
+
if (!entry || Date.now() - entry.fetchedAt > maxAge) return null;
|
|
159
|
+
entries.delete(key);
|
|
160
|
+
entries.set(key, entry); // most recently used
|
|
161
|
+
return entry;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* fetchAndParse asks for HTML (Accept: text/html…); fetchWithTimeout sends no
|
|
166
|
+
* Accept. A server that answered "any type" with HTML has an HTML variant, so
|
|
167
|
+
* a request preferring HTML gets the same: that copy serves fetchAndParse. The
|
|
168
|
+
* other direction is not safe (an API may answer `*\/*` with JSON).
|
|
169
|
+
*/
|
|
170
|
+
function lookupHtmlFromPlain(url, options, maxAge) {
|
|
171
|
+
const entry = lookup(pageCacheKey(url, options, 'plain'), maxAge);
|
|
172
|
+
const type = (entry && pageHeader(entry, 'content-type')) || '';
|
|
173
|
+
return /^\s*(?:text\/html|application\/xhtml\+xml)\b/i.test(type) ? entry : null;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async function serveHit(url, options, entry, fromEntry) {
|
|
177
|
+
// The blocklist and robots.txt are asked again; only the request is skipped.
|
|
178
|
+
const decision = await preflightDecision(url, options);
|
|
179
|
+
if (entry.redirected && entry.url) await preflightDecision(entry.url, options, { redirectedFrom: url });
|
|
180
|
+
recordPageCache('hit', entry.fetchedAt);
|
|
181
|
+
return fromEntry(entry, decision.warnings);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Serve a page from the cache, or fetch it once and keep it.
|
|
186
|
+
*
|
|
187
|
+
* @param {string} url
|
|
188
|
+
* @param {{ maxAge?: number, noCache?: boolean, headers?: object, userAgent?: string,
|
|
189
|
+
* respectRobots?: boolean, apiKey?: string, method?: string, errorDocuments?: boolean }} options
|
|
190
|
+
* @param {'plain'|'html'} variant the seam asking
|
|
191
|
+
* @param {() => Promise<{ result: any, page: object }>} fetchFresh the seam's
|
|
192
|
+
* own fetch: its usual return value plus the page as pageFromResponse gives it
|
|
193
|
+
* @param {(entry: object, warnings: string[]) => any} fromEntry the seam's
|
|
194
|
+
* return value built from a stored page
|
|
195
|
+
*/
|
|
196
|
+
export async function throughPageCache(url, options, variant, fetchFresh, fromEntry) {
|
|
197
|
+
if (!pageCacheActive()) return (await fetchFresh()).result;
|
|
198
|
+
|
|
199
|
+
const key = options.noCache === true ? null : pageCacheKey(url, options, variant);
|
|
200
|
+
if (key === null) {
|
|
201
|
+
const { result, page } = await fetchFresh();
|
|
202
|
+
recordPageCache('bypass', page?.fetchedAt);
|
|
203
|
+
return result;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const maxAge = Number.isFinite(options.maxAge) && options.maxAge >= 0 ? options.maxAge : DEFAULT_MAX_AGE_MS;
|
|
207
|
+
// A caller that reads error bodies (errorDocuments) must not inherit the
|
|
208
|
+
// throw of one that does not, so the two never share an in-flight fetch.
|
|
209
|
+
const flightKey = options.errorDocuments ? `${key}:errors` : key;
|
|
210
|
+
|
|
211
|
+
if (maxAge > 0) {
|
|
212
|
+
const entry = lookup(key, maxAge) ?? (variant === 'html' ? lookupHtmlFromPlain(url, options, maxAge) : null);
|
|
213
|
+
if (entry) return serveHit(url, options, entry, fromEntry);
|
|
214
|
+
const pending = inflight.get(flightKey);
|
|
215
|
+
if (pending) {
|
|
216
|
+
// A failed fetch rejects every caller waiting on it; nothing is stored.
|
|
217
|
+
const shared = await pending;
|
|
218
|
+
if (shared.entry) return serveHit(url, options, shared.entry, fromEntry);
|
|
219
|
+
// Not storable (non-2xx, a wall): this call makes its own request.
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const run = fetchFresh().then(({ result, page }) => ({ result, page, entry: page ? store(key, page) : null }));
|
|
224
|
+
if (!inflight.has(flightKey)) inflight.set(flightKey, run);
|
|
225
|
+
try {
|
|
226
|
+
const { result, page } = await run;
|
|
227
|
+
recordPageCache(maxAge > 0 ? 'miss' : 'bypass', page?.fetchedAt);
|
|
228
|
+
return result;
|
|
229
|
+
} finally {
|
|
230
|
+
if (inflight.get(flightKey) === run) inflight.delete(flightKey);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Bytes currently held (for tests and diagnostics). */
|
|
235
|
+
export function pageCacheBytes() {
|
|
236
|
+
return totalBytes;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Empty the cache. Tests pass `{ enabled: true }` to turn it on under
|
|
241
|
+
* NODE_ENV=test or node --test, optionally with a small `maxBytes`; no argument restores the
|
|
242
|
+
* defaults.
|
|
243
|
+
*/
|
|
244
|
+
export function _resetPageCache(override = null) {
|
|
245
|
+
entries.clear();
|
|
246
|
+
inflight.clear();
|
|
247
|
+
totalBytes = 0;
|
|
248
|
+
testOverride = override;
|
|
249
|
+
}
|
|
@@ -26,6 +26,15 @@ const costShape = z.object({
|
|
|
26
26
|
projection_note: z.string().optional().describe('Human-readable note about how the cost was projected')
|
|
27
27
|
}).passthrough().optional().describe('Cost-transparency metadata (D3.5): what this call was projected to cost and what it was charged');
|
|
28
28
|
|
|
29
|
+
// `cache` is injected the same way when the call fetched through the page
|
|
30
|
+
// cache (REPEAT_CALL_REDUCTION_PLAN 1.6).
|
|
31
|
+
const cacheShape = z.object({
|
|
32
|
+
state: z.enum(['hit', 'miss', 'bypass']).optional().describe('hit: every page came from this session\'s copy; bypass: fetched fresh on request (maxAge 0) or not cacheable; miss: fetched'),
|
|
33
|
+
fetchedAt: z.string().nullable().optional().describe('When the oldest page used was fetched (ISO 8601)'),
|
|
34
|
+
ageMs: z.number().nullable().optional().describe('Age of that copy in ms'),
|
|
35
|
+
note: z.string().optional().describe('Hits only: how old the copy is and how to force a fresh fetch')
|
|
36
|
+
}).passthrough().optional().describe('Page cache: whether this call reused a copy of the page fetched earlier in the session');
|
|
37
|
+
|
|
29
38
|
const nextStepShape = z.string().optional().describe('Failed calls only: the tool or fix to try next');
|
|
30
39
|
|
|
31
40
|
// ── result handles (Phase 2) ────────────────────────────────────────────────
|
|
@@ -144,6 +153,7 @@ const scrapeShape = {
|
|
|
144
153
|
redaction: redactionShape,
|
|
145
154
|
...resultHandleShape,
|
|
146
155
|
_cost: costShape,
|
|
156
|
+
cache: cacheShape,
|
|
147
157
|
next_step: nextStepShape
|
|
148
158
|
};
|
|
149
159
|
|
|
@@ -188,6 +198,7 @@ const mapSiteShape = {
|
|
|
188
198
|
warnings: z.array(z.string()).optional().describe('Present when the map is partial, e.g. the start page could not be read and the URLs come from the sitemap only, or when the result is over max_inline_chars'),
|
|
189
199
|
...resultHandleShape,
|
|
190
200
|
_cost: costShape,
|
|
201
|
+
cache: cacheShape,
|
|
191
202
|
next_step: nextStepShape
|
|
192
203
|
};
|
|
193
204
|
|
|
@@ -223,6 +234,7 @@ const serpRankShape = {
|
|
|
223
234
|
cost: z.number().optional().describe('USD charged by DataForSEO for this lookup (separate from CrawlForge credits)'),
|
|
224
235
|
checkedAt: z.string().optional(),
|
|
225
236
|
_cost: costShape,
|
|
237
|
+
cache: cacheShape,
|
|
226
238
|
next_step: nextStepShape
|
|
227
239
|
};
|
|
228
240
|
|
|
@@ -277,6 +289,7 @@ const redditSearchShape = {
|
|
|
277
289
|
notes: z.array(z.string()).optional().describe('Data-provenance caveats (archive freshness, coverage gaps)'),
|
|
278
290
|
checkedAt: z.string().optional(),
|
|
279
291
|
_cost: costShape,
|
|
292
|
+
cache: cacheShape,
|
|
280
293
|
next_step: nextStepShape
|
|
281
294
|
};
|
|
282
295
|
|
|
@@ -339,6 +352,7 @@ const searchWebShape = {
|
|
|
339
352
|
warnings: z.array(z.string()).optional().describe('Notes on this result; over max_inline_chars, where the full result is kept and how to read it'),
|
|
340
353
|
...resultHandleShape,
|
|
341
354
|
_cost: costShape,
|
|
355
|
+
cache: cacheShape,
|
|
342
356
|
next_step: nextStepShape
|
|
343
357
|
};
|
|
344
358
|
|
|
@@ -373,6 +387,7 @@ const extractStructuredShape = {
|
|
|
373
387
|
skipped: z.string().optional().describe('"empty_source" when there was nothing to check against')
|
|
374
388
|
}).passthrough().optional(),
|
|
375
389
|
_cost: costShape,
|
|
390
|
+
cache: cacheShape,
|
|
376
391
|
next_step: nextStepShape
|
|
377
392
|
};
|
|
378
393
|
|
|
@@ -423,8 +438,10 @@ const crawlDeepShape = {
|
|
|
423
438
|
crawled_at: z.string().optional().describe('When the pages were actually fetched (ISO 8601)'),
|
|
424
439
|
cached: z.boolean().optional().describe('True when this response was replayed from an earlier crawl rather than crawled now; crawled_at gives its age'),
|
|
425
440
|
redaction: redactionShape,
|
|
441
|
+
warnings: z.array(z.string()).optional().describe('Notes on this result; over max_inline_chars, where the full result is kept and how to read it'),
|
|
426
442
|
...resultHandleShape,
|
|
427
443
|
_cost: costShape,
|
|
444
|
+
cache: cacheShape,
|
|
428
445
|
next_step: nextStepShape
|
|
429
446
|
};
|
|
430
447
|
|
|
@@ -464,6 +481,7 @@ const readResultShape = {
|
|
|
464
481
|
truncated: z.boolean().optional().describe('search: more matches than returned; json_path: value replaced by a preview'),
|
|
465
482
|
warnings: z.array(z.string()).optional(),
|
|
466
483
|
_cost: costShape,
|
|
484
|
+
cache: cacheShape,
|
|
467
485
|
next_step: nextStepShape
|
|
468
486
|
};
|
|
469
487
|
|
|
@@ -222,10 +222,10 @@ function warningsOf(resultObject) {
|
|
|
222
222
|
* @param {string} toolName
|
|
223
223
|
* @param {object} resultObject — the parsed JSON result
|
|
224
224
|
* @param {object} params — the tool params (max_inline_chars, url, operation)
|
|
225
|
-
* @param {{ store: import('../core/ResultStore.js').ResultStore, env?: object }} deps
|
|
225
|
+
* @param {{ store: import('../core/ResultStore.js').ResultStore, env?: object, reserve?: number }} deps
|
|
226
226
|
* @returns {{ result: object, stored: boolean }}
|
|
227
227
|
*/
|
|
228
|
-
export function applyInlineThreshold(toolName, resultObject, params, { store, env = process.env }) {
|
|
228
|
+
export function applyInlineThreshold(toolName, resultObject, params, { store, env = process.env, reserve = COST_BLOCK_RESERVE }) {
|
|
229
229
|
const unchanged = { result: resultObject, stored: false };
|
|
230
230
|
const config = INLINE_THRESHOLD_TOOLS[toolName];
|
|
231
231
|
if (!config || (config.when && !config.when(params))) return unchanged;
|
|
@@ -254,7 +254,7 @@ export function applyInlineThreshold(toolName, resultObject, params, { store, en
|
|
|
254
254
|
const readWith = 'read it with read_result (1 credit) - operation "search" (query), "slice" (offset, length), "lines" or "json_path" (path) - and do not fetch the page again';
|
|
255
255
|
|
|
256
256
|
if (toolName === 'extract_embedded_state') {
|
|
257
|
-
return { result: shapeEmbeddedState(resultObject, { json, text, maxInline, handle, expires_at, readWith }), stored: true };
|
|
257
|
+
return { result: shapeEmbeddedState(resultObject, { json, text, maxInline, handle, expires_at, readWith, reserve }), stored: true };
|
|
258
258
|
}
|
|
259
259
|
|
|
260
260
|
const preview = text.slice(0, maxInline);
|
|
@@ -316,6 +316,13 @@ export function applyInlineThreshold(toolName, resultObject, params, { store, en
|
|
|
316
316
|
*/
|
|
317
317
|
export const COST_BLOCK_RESERVE = 400;
|
|
318
318
|
|
|
319
|
+
/**
|
|
320
|
+
* Extra room when the call went through the page cache: the `cache` block
|
|
321
|
+
* with its hit note (~250 chars) and the longer projection note (~70 chars).
|
|
322
|
+
* withAuth passes it only then, so other calls keep their preview budget.
|
|
323
|
+
*/
|
|
324
|
+
export const CACHE_BLOCK_RESERVE = 350;
|
|
325
|
+
|
|
319
326
|
/** Object.keys(data) for a plain object, "array(<n>)" for an array, null otherwise. */
|
|
320
327
|
export function embeddedDataKeys(data) {
|
|
321
328
|
if (Array.isArray(data)) return `array(${data.length})`;
|
|
@@ -359,7 +366,7 @@ export function wholeLinePreview(text, budget) {
|
|
|
359
366
|
* under the handle for read_result json_path. The REST route shapes it the
|
|
360
367
|
* same way, measured as the compact JSON it sends; keep the two in step.
|
|
361
368
|
*/
|
|
362
|
-
function shapeEmbeddedState(resultObject, { json, text, maxInline, handle, expires_at, readWith }) {
|
|
369
|
+
function shapeEmbeddedState(resultObject, { json, text, maxInline, handle, expires_at, readWith, reserve = COST_BLOCK_RESERVE }) {
|
|
363
370
|
const dataKeys = embeddedDataKeys(resultObject.data);
|
|
364
371
|
const firstKey = Array.isArray(dataKeys) && dataKeys.length > 0 ? dataKeys[0] : null;
|
|
365
372
|
const example = firstKey && /^[A-Za-z_$][\w$]*$/.test(firstKey) ? ` Read one payload with operation "json_path", path "data.${firstKey}".` : '';
|
|
@@ -386,7 +393,7 @@ function shapeEmbeddedState(resultObject, { json, text, maxInline, handle, expir
|
|
|
386
393
|
};
|
|
387
394
|
// The preview's value is one line of the pretty-printed text and escapes
|
|
388
395
|
// the same way there as in compact JSON; `""` is its empty value's quotes.
|
|
389
|
-
const envelope = JSON.stringify({ ...shaped, preview: '', ...tail }, null, 2).length +
|
|
396
|
+
const envelope = JSON.stringify({ ...shaped, preview: '', ...tail }, null, 2).length + reserve;
|
|
390
397
|
const preview = wholeLinePreview(text, Math.max(0, maxInline - envelope + 2));
|
|
391
398
|
return { ...shaped, preview, ...tail };
|
|
392
399
|
}
|
|
@@ -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
|
+
}
|