crawlforge-mcp-server 5.6.10 → 5.8.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/CLAUDE.md +5 -4
- package/README.md +8 -5
- package/package.json +3 -3
- package/server.js +49 -37
- package/src/core/AuthManager.js +18 -3
- package/src/core/ResultStore.js +230 -0
- package/src/schemas/toolOutputSchemas.js +106 -12
- package/src/server/fallbackHints.js +1 -0
- package/src/server/inlineThreshold.js +149 -0
- package/src/server/requestContext.js +22 -0
- package/src/server/toolFilter.js +11 -4
- package/src/server/withAuth.js +40 -6
- package/src/skills/agent-skills/crawlforge-batch-automation/SKILL.md +2 -1
- package/src/skills/agent-skills/crawlforge-getting-started/SKILL.md +5 -4
- package/src/skills/agent-skills/crawlforge-getting-started/references/credits.md +4 -1
- package/src/skills/agent-skills/crawlforge-web-scraping/SKILL.md +43 -1
- package/src/skills/agent-skills/crawlforge-web-scraping/references/tool-reference.md +27 -1
- package/src/tools/advanced/batchScrape/index.js +23 -43
- package/src/tools/extract/_fetchAndParse.js +19 -10
- package/src/tools/result/readResult.js +147 -0
- package/src/tools/scrape/_highlights.js +79 -0
- package/src/tools/scrape/formats.js +69 -0
- package/src/tools/scrape/unifiedScrape.js +232 -31
- package/src/utils/challengeDetection.js +6 -75
- package/src/utils/hostRateLimiter.js +54 -4
- package/src/utils/secretMask.js +27 -1
- package/src/utils/stealthVerdict.js +6 -78
package/CLAUDE.md
CHANGED
|
@@ -60,7 +60,7 @@ These guidelines are working if: fewer unnecessary changes in diffs, fewer rewri
|
|
|
60
60
|
|
|
61
61
|
## Project Overview
|
|
62
62
|
|
|
63
|
-
CrawlForge MCP Server - A professional MCP (Model Context Protocol) server providing
|
|
63
|
+
CrawlForge MCP Server - A professional MCP (Model Context Protocol) server providing 30 web scraping, crawling, and content processing tools (6 inline + 24 advanced).
|
|
64
64
|
|
|
65
65
|
**Current Version:** 5.6.6
|
|
66
66
|
|
|
@@ -162,18 +162,19 @@ Tools are organized in subdirectories by category:
|
|
|
162
162
|
- `crawl/` - crawlDeep, mapSite
|
|
163
163
|
- `extract/` - analyzeContent, extractContent, extractStructured, extractWithLlm, listOllamaModels, processDocument, summarizeContent
|
|
164
164
|
- `research/` - deepResearch
|
|
165
|
+
- `result/` - readResult (`read_result`: search/slice/lines/json_path over a stored over-threshold result by `result_handle`, v5.8.0)
|
|
165
166
|
- `search/` - searchWeb (proxied through CrawlForge.dev API), serpRank (real Google organic rank via DataForSEO; adapter in `search/adapters/dataforseoSearch.js`), redditSearch (Reddit posts/comments/threads via Arctic Shift + PullPush community archives — reddit.com blocks scrapers)
|
|
166
167
|
- `templates/` - ScrapeTemplateTool (10 pre-built site templates, v4.0.0)
|
|
167
168
|
- `tracking/` - trackChanges
|
|
168
169
|
- `llmstxt/` - generateLLMsTxt
|
|
169
170
|
|
|
170
|
-
### Available MCP Tools (
|
|
171
|
+
### Available MCP Tools (30 total)
|
|
171
172
|
|
|
172
173
|
**Basic Tools (server.js inline, 6):**
|
|
173
174
|
fetch_url, extract_text, extract_links, extract_metadata, extract_embedded_state, scrape_structured
|
|
174
175
|
|
|
175
|
-
**Advanced Tools (
|
|
176
|
-
search_web, serp_rank, reddit_search, crawl_deep, map_site, extract_content, process_document, summarize_content, analyze_content, extract_structured, extract_with_llm, list_ollama_models, batch_scrape, get_batch_results, scrape_with_actions, deep_research, track_changes, generate_llms_txt, stealth_mode, localization, scrape_template, scrape, agent
|
|
176
|
+
**Advanced Tools (24):**
|
|
177
|
+
search_web, serp_rank, reddit_search, crawl_deep, map_site, extract_content, process_document, summarize_content, analyze_content, extract_structured, extract_with_llm, list_ollama_models, batch_scrape, get_batch_results, scrape_with_actions, deep_research, track_changes, generate_llms_txt, stealth_mode, localization, scrape_template, scrape, agent, read_result
|
|
177
178
|
|
|
178
179
|
**serp_rank (DataForSEO):**
|
|
179
180
|
- `serp_rank` — reports where a target domain ranks in Google's REAL organic results for a keyword (the SERP position Google Custom Search / `search_web` cannot give). Backed by the DataForSEO Google Organic SERP API (Live Advanced, `POST /v3/serp/google/organic/live/advanced`, HTTP Basic auth). Credentials via `DATAFORSEO_LOGIN` / `DATAFORSEO_PASSWORD`, billed to the user's own DataForSEO account (~US$0.002 per 10 results of `depth`, so $0.004 at the default `depth:20` and $0.02 at `depth:100`), separate from CrawlForge credits. When unconfigured it returns `{ configured:false }` and charges **0** credits; when configured, **Cost: 5**. Never fabricates a rank.
|
package/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
</p>
|
|
4
4
|
|
|
5
5
|
<p align="center">
|
|
6
|
-
<b>
|
|
6
|
+
<b>30 web scraping, crawling, deep-research & autonomous-extraction tools for Claude, Cursor & any MCP client.</b><br>
|
|
7
7
|
Clean Markdown & structured JSON from any site. Get started with <b>1,000 free credits</b> — no credit card required.
|
|
8
8
|
</p>
|
|
9
9
|
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
|
|
36
36
|
## 🎯 Why CrawlForge?
|
|
37
37
|
|
|
38
|
-
- **
|
|
38
|
+
- **30 MCP-native tools** — scraping, crawling, search, real Google SERP rank tracking, deep research, an autonomous `agent`, a unified multi-format `scrape`, document processing, stealth browsing, and more, callable directly from your AI assistant.
|
|
39
39
|
- **Generous free tier** — 1,000 credits to start instantly, no credit card. The grant is one-time rather than monthly, and the credits never expire.
|
|
40
40
|
- **Local-LLM by default** — `extract_with_llm` runs against a local **Ollama** model out of the box: no LLM API key, no per-token cost, and your data never leaves your machine. Cloud (OpenAI/Anthropic) is opt-in.
|
|
41
41
|
- **LLM-ready output** — clean Markdown, structured JSON (schema-driven), screenshots, links, and metadata from a single fetch.
|
|
@@ -47,7 +47,7 @@
|
|
|
47
47
|
|
|
48
48
|
| | **CrawlForge MCP** | Firecrawl | Raw scraping API |
|
|
49
49
|
|---|:---:|:---:|:---:|
|
|
50
|
-
| Native MCP server | ✅
|
|
50
|
+
| Native MCP server | ✅ 30 tools | ✅ | ❌ |
|
|
51
51
|
| Free tier | ✅ 1,000 credits, rollover | Limited | Varies |
|
|
52
52
|
| Self-hosted / local LLM extraction (Ollama) | ✅ default, $0/token | ❌ | ❌ |
|
|
53
53
|
| Autonomous agent (no URLs needed) | ✅ `agent` | ✅ | ❌ |
|
|
@@ -178,7 +178,8 @@ CrawlForge requires a CrawlForge API key — **every tool is metered and consume
|
|
|
178
178
|
| `scrape_template` | 1 | Structured data from well-known sites (Amazon, GitHub, LinkedIn, YouTube, Reddit, Hacker News, npm, and more) without writing selectors |
|
|
179
179
|
| `list_ollama_models` | 1 | List the Ollama models installed locally (helps you pick a `model` for `extract_with_llm`) |
|
|
180
180
|
| `get_batch_results` | 1 | Retrieve paginated results for a `batch_scrape` job by `batchId` |
|
|
181
|
-
| `
|
|
181
|
+
| `read_result` | 1 | Search, slice, read lines or a JSON path from a result a tool returned with `truncated: true` and a `result_handle` (kept 1 hour on your own machine) — never fetch the page again |
|
|
182
|
+
| `scrape` | 2 | **Unified single-fetch, multi-format extraction.** Pass a `formats` array (markdown/html/rawHtml/text/links/metadata/screenshot/json-schema, plus `{type:"highlights",query}` and `{type:"question",question}` for only the matching sentences, table rows and code blocks, verbatim with offsets into the markdown: +1 credit once per call, `mode:"model"` +3) plus `onlyMainContent`; one fetch serves every requested format with per-format partial-success warnings |
|
|
182
183
|
| `scrape_structured` | 2 | Extract structured data with CSS selectors |
|
|
183
184
|
| `extract_embedded_state` | 2 | Read a page's embedded JavaScript state — `__NEXT_DATA__`, React Server Component payloads, Nuxt, Apollo, Redux, `<script type="application/json">` — with a `path` to scope the result. No LLM in the extraction path |
|
|
184
185
|
| `extract_content` | 2 | Enhanced content extraction |
|
|
@@ -201,6 +202,8 @@ CrawlForge requires a CrawlForge API key — **every tool is metered and consume
|
|
|
201
202
|
| `agent` | 8 | **Autonomous research/extraction from a natural-language prompt — no URLs required.** Plans, gathers, and shapes an answer under hard safety stops (max steps/URLs/wall-clock enforced by the orchestrator, never the LLM) |
|
|
202
203
|
| `deep_research` | 10 | Multi-stage research with source verification |
|
|
203
204
|
|
|
205
|
+
Ten tools (`scrape`, `fetch_url`, `extract_content`, `crawl_deep`, `batch_scrape`, `stealth_mode`, `scrape_with_actions`, `process_document`, `deep_research`, `extract_embedded_state`) accept `max_inline_chars` (default 40,000; env `CRAWLFORGE_MAX_INLINE_CHARS`): a result over it comes back as a `preview` plus a `result_handle` for `read_result`, with the full result kept for 1 hour under `~/.crawlforge/results/` on your own machine — nothing is uploaded.
|
|
206
|
+
|
|
204
207
|
For the full canonical capabilities reference (all tools, CLI commands, stealth engines, research workflow), see [SKILL.md](docs/SKILL.md).
|
|
205
208
|
|
|
206
209
|
<p align="right"><a href="#table-of-contents">↑ Back to top</a></p>
|
|
@@ -217,7 +220,7 @@ For the full canonical capabilities reference (all tools, CLI commands, stealth
|
|
|
217
220
|
| **Business** ($399) | 250,000 / month | Large scale operations |
|
|
218
221
|
|
|
219
222
|
**All plans include:**
|
|
220
|
-
- Access to all
|
|
223
|
+
- Access to all 30 tools
|
|
221
224
|
- Credits never expire; paid-plan credits roll over month to month
|
|
222
225
|
- API access and webhook notifications
|
|
223
226
|
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "crawlforge-mcp-server",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.8.0",
|
|
4
4
|
"mcpName": "io.github.mysleekdesigns/crawlforge-mcp-server",
|
|
5
|
-
"description": "CrawlForge MCP Server - Professional Model Context Protocol server with
|
|
5
|
+
"description": "CrawlForge MCP Server - Professional Model Context Protocol server with 30 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",
|
|
7
7
|
"bin": {
|
|
8
8
|
"crawlforge": "src/cli/index.js",
|
|
@@ -116,7 +116,7 @@
|
|
|
116
116
|
"cheerio": "^1.1.2",
|
|
117
117
|
"commander": "^14.0.3",
|
|
118
118
|
"compromise": "^14.14.4",
|
|
119
|
-
"crawlforge-extractors": "^1.
|
|
119
|
+
"crawlforge-extractors": "^1.8.0",
|
|
120
120
|
"diff": "^9.0.0",
|
|
121
121
|
"dotenv": "^17.2.1",
|
|
122
122
|
"franc": "^6.2.0",
|
package/server.js
CHANGED
|
@@ -26,7 +26,7 @@ import { DeepResearchTool } from "./src/tools/research/deepResearch.js";
|
|
|
26
26
|
import { TrackChangesTool } from "./src/tools/tracking/trackChanges/index.js";
|
|
27
27
|
import { GenerateLLMsTxtTool } from "./src/tools/llmstxt/generateLLMsTxt.js";
|
|
28
28
|
import { ScrapeTemplateTool } from "./src/tools/templates/ScrapeTemplateTool.js"; // D3.3
|
|
29
|
-
import { UnifiedScrapeTool } from "./src/tools/scrape/unifiedScrape.js"; // D4 D1
|
|
29
|
+
import { UnifiedScrapeTool, SCRAPE_INPUT_SHAPE } from "./src/tools/scrape/unifiedScrape.js"; // D4 D1
|
|
30
30
|
import { AgentTool } from "./src/tools/agent/agent.js"; // D4 D2
|
|
31
31
|
import { StealthBrowserManager } from "./src/core/StealthBrowserManager.js";
|
|
32
32
|
import { LocalizationManager } from "./src/core/LocalizationManager.js";
|
|
@@ -55,6 +55,8 @@ import { extractLinksHandler } from "./src/tools/basic/extractLinks.js";
|
|
|
55
55
|
import { extractMetadataHandler } from "./src/tools/basic/extractMetadata.js";
|
|
56
56
|
import { scrapeStructuredHandler } from "./src/tools/basic/scrapeStructured.js";
|
|
57
57
|
import { extractEmbeddedStateHandler } from "./src/tools/extract/extractEmbeddedState.js";
|
|
58
|
+
import { READ_RESULT_INPUT_SHAPE, readResultHandler } from "./src/tools/result/readResult.js"; // Phase 2
|
|
59
|
+
import { MAX_INLINE_CHARS_PARAM } from "./src/server/inlineThreshold.js"; // Phase 2
|
|
58
60
|
// D1.1 Resources + D1.2 Prompts + D1.4 Elicitation
|
|
59
61
|
import { ResourceRegistry } from "./src/resources/ResourceRegistry.js";
|
|
60
62
|
import { PROMPTS, getPromptMessages } from "./src/prompts/PromptRegistry.js";
|
|
@@ -107,8 +109,8 @@ const taskStore = createTaskStore({ logger });
|
|
|
107
109
|
// Create the server
|
|
108
110
|
const server = new McpServer({
|
|
109
111
|
name: "crawlforge",
|
|
110
|
-
version: "5.
|
|
111
|
-
description: "Production-ready MCP server with
|
|
112
|
+
version: "5.8.0",
|
|
113
|
+
description: "Production-ready MCP server with 30 web scraping, crawling, and content processing tools. Features MCP Resources (crawlforge://), Prompts, Sampling fallback, Elicitation, stealth browsing, 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.",
|
|
112
114
|
homepage: "https://www.crawlforge.dev",
|
|
113
115
|
icon: "https://www.crawlforge.dev/icon.png",
|
|
114
116
|
icons: [{ src: "https://www.crawlforge.dev/icon.png", mimeType: "image/png", sizes: ["any"] }],
|
|
@@ -125,6 +127,7 @@ const server = new McpServer({
|
|
|
125
127
|
"- Exact values from a Next.js/Nuxt/Redux payload -> extract_embedded_state (2) with a path.",
|
|
126
128
|
"- Known CSS selectors -> scrape_structured (2); fields you can describe but not select -> extract_structured (3).",
|
|
127
129
|
"- A report from several sources -> ONE deep_research call (10 + ~1 per 5 sources); it replaces a search_web + scrape fan-out that costs 5 per search and 2 per page. Open question with no URLs -> agent (8).",
|
|
130
|
+
"- A result that came back truncated: true with a result_handle -> read_result (1): search, slice, lines or json_path over the stored result; never fetch the page again.",
|
|
128
131
|
"Rules: never fetch a URL whose content is already in this conversation - reuse it. One call per page: scrape with several formats replaces fetch_url + extract_* pairs. Error results end with \"Next step:\" naming the tool to try; follow it instead of retrying the same call. Use the client's built-in web search/fetch only when CrawlForge is unavailable or out of credits."
|
|
129
132
|
].join("\n"),
|
|
130
133
|
taskStore
|
|
@@ -142,7 +145,7 @@ server.registerPrompt("getting-started", {
|
|
|
142
145
|
role: "user",
|
|
143
146
|
content: {
|
|
144
147
|
type: "text",
|
|
145
|
-
text: "You have access to CrawlForge MCP:
|
|
148
|
+
text: "You have access to CrawlForge MCP: 30 metered web tools (credits per call, shown in brackets). Pick one tool per step from this ladder, and reuse content already in the conversation instead of fetching it again.\n" +
|
|
146
149
|
"\n" +
|
|
147
150
|
"Read a page\n" +
|
|
148
151
|
"- scrape (2): one URL, every format you need in one call - markdown, links, metadata, html, screenshot, json. Default for any page whose URL you have.\n" +
|
|
@@ -176,6 +179,9 @@ server.registerPrompt("getting-started", {
|
|
|
176
179
|
"- deep_research (10): multi-source report. agent (8): open question, no URLs.\n" +
|
|
177
180
|
"- track_changes (3): create_baseline, then compare. generate_llms_txt (5): write a site's llms.txt.\n" +
|
|
178
181
|
"\n" +
|
|
182
|
+
"Large results\n" +
|
|
183
|
+
"- read_result (1): a result that came back truncated: true with a result_handle - search, slice, lines or json_path over the stored result; never fetch the page again.\n" +
|
|
184
|
+
"\n" +
|
|
179
185
|
"Error results end with \"Next step:\" naming the tool to try - follow it rather than retrying the same call.\n" +
|
|
180
186
|
"\n" +
|
|
181
187
|
"Get your API key at https://www.crawlforge.dev/signup (1,000 free credits)"
|
|
@@ -256,6 +262,7 @@ agentTool.setMcpServer(server); // D4 D2: SamplingClient + Elicitation
|
|
|
256
262
|
trackChangesTool.setMcpServer(server); // v4.8: SamplingClient for scheduled-monitor goal judging
|
|
257
263
|
extractWithLlmTool.setMcpServer(server); // SamplingClient fallback
|
|
258
264
|
summarizeContentTool.setMcpServer(server); // SamplingClient fallback
|
|
265
|
+
unifiedScrapeTool.setMcpServer(server); // SamplingClient for the highlights/question mode:"model" step
|
|
259
266
|
AuthManager.setElicitation(elicitation);
|
|
260
267
|
|
|
261
268
|
// ─── D1.1 Resource Templates (MCP Resources) ─────────────────────────────────
|
|
@@ -354,7 +361,8 @@ registerToolIfEnabled("fetch_url", {
|
|
|
354
361
|
url: z.string().url().describe("The URL to fetch content from"),
|
|
355
362
|
headers: z.record(z.string()).optional().describe("Custom HTTP headers to include in the request"),
|
|
356
363
|
timeout: z.number().min(1000).max(30000).optional().default(10000).describe("Request timeout in milliseconds (1000-30000)"),
|
|
357
|
-
...COMPLIANCE_PARAMS
|
|
364
|
+
...COMPLIANCE_PARAMS,
|
|
365
|
+
...MAX_INLINE_CHARS_PARAM
|
|
358
366
|
}
|
|
359
367
|
}, withAuth("fetch_url", fetchUrlHandler));
|
|
360
368
|
|
|
@@ -401,7 +409,8 @@ registerToolIfEnabled("extract_embedded_state", {
|
|
|
401
409
|
inputSchema: {
|
|
402
410
|
url: z.string().url().describe("The URL to read embedded state from"),
|
|
403
411
|
path: z.string().optional().describe("Return only this subtree instead of the whole payload. Dotted keys and array indexes, e.g. \"next_data.props.pageProps\" or \"next_f[0].f\" — not JSONPath (no wildcards, filters or recursion). State payloads are routinely over a megabyte; scope them."),
|
|
404
|
-
...COMPLIANCE_PARAMS
|
|
412
|
+
...COMPLIANCE_PARAMS,
|
|
413
|
+
...MAX_INLINE_CHARS_PARAM
|
|
405
414
|
}
|
|
406
415
|
}, withAuth("extract_embedded_state", extractEmbeddedStateHandler));
|
|
407
416
|
|
|
@@ -574,7 +583,8 @@ if (toolFilter.isEnabled("crawl_deep")) {
|
|
|
574
583
|
headers: z.record(z.string()).optional(),
|
|
575
584
|
body: z.string().optional()
|
|
576
585
|
}).optional()
|
|
577
|
-
}).optional().describe("Shared cookie-jar/session for login-then-crawl workflows")
|
|
586
|
+
}).optional().describe("Shared cookie-jar/session for login-then-crawl workflows"),
|
|
587
|
+
...MAX_INLINE_CHARS_PARAM
|
|
578
588
|
},
|
|
579
589
|
outputSchema: OUTPUT_SCHEMAS.crawl_deep,
|
|
580
590
|
execution: TASK_EXECUTION
|
|
@@ -636,7 +646,8 @@ registerToolIfEnabled("extract_content", {
|
|
|
636
646
|
inputSchema: {
|
|
637
647
|
url: z.string().url().describe("The URL to extract content from"),
|
|
638
648
|
options: z.object({}).passthrough().optional().describe("Additional extraction options"),
|
|
639
|
-
...COMPLIANCE_PARAMS
|
|
649
|
+
...COMPLIANCE_PARAMS,
|
|
650
|
+
...MAX_INLINE_CHARS_PARAM
|
|
640
651
|
}
|
|
641
652
|
}, withAuth("extract_content", async (params) => {
|
|
642
653
|
try {
|
|
@@ -660,7 +671,8 @@ registerToolIfEnabled("process_document", {
|
|
|
660
671
|
// C3: passthrough so granular options (maxPages, pageRange:{start,end},
|
|
661
672
|
// extractText, outputFormat, etc.) reach the tool instead of being stripped.
|
|
662
673
|
options: z.object({}).passthrough().optional().describe("Additional processing options (maxPages, pageRange:{start,end}, extractText, extractMetadata, outputFormat, ...)"),
|
|
663
|
-
...COMPLIANCE_PARAMS
|
|
674
|
+
...COMPLIANCE_PARAMS,
|
|
675
|
+
...MAX_INLINE_CHARS_PARAM
|
|
664
676
|
}
|
|
665
677
|
}, withAuth("process_document", async (params) => {
|
|
666
678
|
try {
|
|
@@ -825,7 +837,8 @@ if (toolFilter.isEnabled("batch_scrape")) {
|
|
|
825
837
|
maxRetries: z.number().min(0).max(5).default(1),
|
|
826
838
|
tags: z.array(z.string()).default([])
|
|
827
839
|
}).optional().describe("Job management options for async processing"),
|
|
828
|
-
...COMPLIANCE_PARAMS
|
|
840
|
+
...COMPLIANCE_PARAMS,
|
|
841
|
+
...MAX_INLINE_CHARS_PARAM
|
|
829
842
|
},
|
|
830
843
|
execution: TASK_EXECUTION
|
|
831
844
|
}, makeTaskToolHandler({
|
|
@@ -864,6 +877,14 @@ registerToolIfEnabled("get_batch_results", {
|
|
|
864
877
|
}
|
|
865
878
|
}));
|
|
866
879
|
|
|
880
|
+
// Tool: read_result — Phase 2: read a stored result by the handle a truncated result returned
|
|
881
|
+
registerToolIfEnabled("read_result", {
|
|
882
|
+
description: "Use this to read a result that came back with truncated: true and a result_handle - the tool kept the whole result for 1 hour and returned a preview. operation:\"search\" finds a literal query with offsets and context, \"slice\" returns characters from an offset, \"lines\" pages by line, \"json_path\" reads one subtree of a JSON result (crawl_deep pages, batch results, a fetch_url JSON body). Not a fetching tool: never call the original tool again while the handle is valid, and not for a result that arrived whole. Cost: 1 credit. Example: read_result({handle: \"res_…\", operation: \"search\", query: \"pricing\"})",
|
|
883
|
+
annotations: { title: "Read Result", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
884
|
+
inputSchema: READ_RESULT_INPUT_SHAPE,
|
|
885
|
+
outputSchema: OUTPUT_SCHEMAS.read_result
|
|
886
|
+
}, withAuth("read_result", readResultHandler));
|
|
887
|
+
|
|
867
888
|
// Tool: scrape_with_actions
|
|
868
889
|
registerToolIfEnabled("scrape_with_actions", {
|
|
869
890
|
description: "Use this when you must interact with a page before scraping - login, click buttons, fill forms, scroll, or wait for dynamic content to load - for SPAs, login-gated content, or multi-step flows. Actions: wait, click, type, press, scroll, screenshot, executeJavaScript, select (dropdowns), hover, navigate. Set browserOptions.stealth:true to run the chain in the stealth browser. robots.txt is respected on every navigation. Screenshots from this tool are stored as crawlforge://screenshot/{actionId} resources. Not for pages that render without interaction (scrape) and not as the first attempt on a blocked site (stealth_mode operation:\"scrape\"). Cost: 5 credits. Example: scrape_with_actions({url: \"https://app.com/dashboard\", actions: [{type:\"click\",selector:\"#login\"},{type:\"type\",selector:\"#email\",text:\"user@a.com\"}]})",
|
|
@@ -946,7 +967,8 @@ registerToolIfEnabled("scrape_with_actions", {
|
|
|
946
967
|
continueOnActionError: z.boolean().default(false).describe("Continue executing actions if one fails"),
|
|
947
968
|
maxRetries: z.number().min(0).max(3).default(1).describe("Maximum retry attempts on failure"),
|
|
948
969
|
screenshotOnError: z.boolean().default(true).describe("Capture screenshot when an error occurs"),
|
|
949
|
-
respect_robots: COMPLIANCE_PARAMS.respect_robots
|
|
970
|
+
respect_robots: COMPLIANCE_PARAMS.respect_robots,
|
|
971
|
+
...MAX_INLINE_CHARS_PARAM
|
|
950
972
|
}
|
|
951
973
|
}, withAuth("scrape_with_actions", async (params) => {
|
|
952
974
|
try {
|
|
@@ -1023,7 +1045,8 @@ if (toolFilter.isEnabled("deep_research")) {
|
|
|
1023
1045
|
url: z.string().url(),
|
|
1024
1046
|
events: z.array(z.enum(['started', 'progress', 'completed', 'failed'])).optional().default(['completed']),
|
|
1025
1047
|
headers: z.record(z.string()).optional()
|
|
1026
|
-
}).optional().describe("Webhook for progress and completion notifications")
|
|
1048
|
+
}).optional().describe("Webhook for progress and completion notifications"),
|
|
1049
|
+
...MAX_INLINE_CHARS_PARAM
|
|
1027
1050
|
},
|
|
1028
1051
|
execution: TASK_EXECUTION
|
|
1029
1052
|
}, makeTaskToolHandler({
|
|
@@ -1043,33 +1066,16 @@ if (toolFilter.isEnabled("deep_research")) {
|
|
|
1043
1066
|
|
|
1044
1067
|
// Tool: scrape (D4 D1 — unified multi-format single-fetch)
|
|
1045
1068
|
registerToolIfEnabled("scrape", {
|
|
1046
|
-
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. 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. Not for raw API/JSON bodies (fetch_url), a blocked site (stealth_mode), 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\"]})",
|
|
1069
|
+
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. Not for raw API/JSON bodies (fetch_url), a blocked site (stealth_mode), 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\"]})",
|
|
1047
1070
|
annotations: { title: "Scrape (Multi-Format)", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
1048
1071
|
// Claude Code tool search loads only names + instructions at session start; this flag
|
|
1049
1072
|
// ships the full definition too, so the first call needs no ToolSearch round-trip.
|
|
1050
1073
|
_meta: { "anthropic/alwaysLoad": true },
|
|
1074
|
+
// The tool module owns the schema (0.3); this is the same shape it validates with.
|
|
1051
1075
|
inputSchema: {
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
z.object({
|
|
1056
|
-
type: z.literal("json"),
|
|
1057
|
-
schema: z.record(z.any()).optional().describe("JSON schema for extraction"),
|
|
1058
|
-
prompt: z.string().optional().describe("Extraction instruction for the LLM")
|
|
1059
|
-
})
|
|
1060
|
-
])).min(1).optional().default(["markdown"]).describe("Formats to return (default: [\"markdown\"])"),
|
|
1061
|
-
onlyMainContent: z.boolean().optional().default(true).describe("Strip boilerplate via Readability (default: true)"),
|
|
1062
|
-
timeoutMs: z.number().min(1000).max(60000).optional().default(15000).describe("Fetch timeout in ms"),
|
|
1063
|
-
brandingOptions: z.object({
|
|
1064
|
-
fetchLinkedCss: z.boolean().optional().default(true).describe("Fetch linked stylesheets for richer color/font extraction"),
|
|
1065
|
-
maxStylesheets: z.number().min(0).max(20).optional().default(10).describe("Max linked stylesheets to fetch")
|
|
1066
|
-
}).optional().describe("Options for the \"branding\" format"),
|
|
1067
|
-
screenshotOptions: z.object({
|
|
1068
|
-
fullPage: z.boolean().optional().default(false).describe("Capture the full scrollable page"),
|
|
1069
|
-
format: z.enum(["png", "jpeg"]).optional().default("png"),
|
|
1070
|
-
quality: z.number().min(0).max(100).optional().describe("JPEG quality (jpeg only)")
|
|
1071
|
-
}).optional().describe("Options for the \"screenshot\" format"),
|
|
1072
|
-
...COMPLIANCE_PARAMS
|
|
1076
|
+
...SCRAPE_INPUT_SHAPE,
|
|
1077
|
+
...COMPLIANCE_PARAMS,
|
|
1078
|
+
...MAX_INLINE_CHARS_PARAM
|
|
1073
1079
|
},
|
|
1074
1080
|
outputSchema: OUTPUT_SCHEMAS.scrape
|
|
1075
1081
|
}, withAuth("scrape", async (params) => {
|
|
@@ -1089,6 +1095,11 @@ registerToolIfEnabled("scrape", {
|
|
|
1089
1095
|
return shot;
|
|
1090
1096
|
});
|
|
1091
1097
|
}
|
|
1098
|
+
// A blocked verdict (0.1) is an error result: withAuth bills it at the
|
|
1099
|
+
// error rate and appends the hint naming stealth_mode, and the SDK skips
|
|
1100
|
+
// outputSchema validation on isError. The JSON body still carries the
|
|
1101
|
+
// verdict (success:false, error, blocked) the way stealth_mode's does.
|
|
1102
|
+
if (result.success === false) return { ...dualOutput(result), isError: true };
|
|
1092
1103
|
return dualOutput(result);
|
|
1093
1104
|
} catch (error) {
|
|
1094
1105
|
return { content: [{ type: "text", text: `Scrape failed: ${error.message}` }], isError: true };
|
|
@@ -1381,7 +1392,8 @@ registerToolIfEnabled("stealth_mode", {
|
|
|
1381
1392
|
formats: z.array(z.enum(["markdown", "html", "text", "links", "metadata", "screenshot"])).optional().default(["markdown"]).describe("Formats to return from operation:\"scrape\" (default: [\"markdown\"]). \"screenshot\" returns a crawlforge://screenshot/{id} resource URI."),
|
|
1382
1393
|
wait_for: z.number().min(0).max(30000).optional().describe("Extra wait after page load, in ms — for content that renders after DOMContentLoaded"),
|
|
1383
1394
|
verbose: z.boolean().optional().default(false).describe("Return the full generated fingerprint from create_context instead of a summary"),
|
|
1384
|
-
respect_robots: COMPLIANCE_PARAMS.respect_robots
|
|
1395
|
+
respect_robots: COMPLIANCE_PARAMS.respect_robots,
|
|
1396
|
+
...MAX_INLINE_CHARS_PARAM
|
|
1385
1397
|
}
|
|
1386
1398
|
}, withAuth("stealth_mode", async ({ operation, stealthConfig, contextId, urlToTest, url, formats, wait_for, verbose, engine, respect_robots }) => {
|
|
1387
1399
|
try {
|
|
@@ -1730,10 +1742,10 @@ async function runServer() {
|
|
|
1730
1742
|
console.error("Search enabled: true (via CrawlForge proxy)");
|
|
1731
1743
|
|
|
1732
1744
|
const allTools = [
|
|
1733
|
-
"fetch_url", "extract_text", "extract_links", "extract_metadata", "scrape_structured",
|
|
1745
|
+
"fetch_url", "extract_text", "extract_links", "extract_metadata", "scrape_structured", "extract_embedded_state",
|
|
1734
1746
|
"search_web", "serp_rank", "reddit_search", "crawl_deep", "map_site",
|
|
1735
1747
|
"extract_content", "process_document", "summarize_content", "analyze_content",
|
|
1736
|
-
"batch_scrape", "get_batch_results", "scrape_with_actions",
|
|
1748
|
+
"batch_scrape", "get_batch_results", "read_result", "scrape_with_actions",
|
|
1737
1749
|
"deep_research", "track_changes", "generate_llms_txt",
|
|
1738
1750
|
"stealth_mode", "localization", "extract_structured", "extract_with_llm",
|
|
1739
1751
|
"list_ollama_models", "scrape_template", // D3.3
|
package/src/core/AuthManager.js
CHANGED
|
@@ -5,12 +5,18 @@
|
|
|
5
5
|
|
|
6
6
|
// Using native fetch (Node.js 18+)
|
|
7
7
|
import fs from 'fs/promises';
|
|
8
|
+
import { readFileSync } from 'fs';
|
|
8
9
|
import path from 'path';
|
|
9
10
|
import { randomUUID } from 'crypto';
|
|
10
11
|
import { isCreatorModeVerified } from './creatorMode.js';
|
|
11
12
|
import { resolveApiEndpoint } from './endpointGuard.js';
|
|
12
13
|
import { logger } from '../utils/Logger.js';
|
|
13
14
|
import { maskSecrets } from '../utils/secretMask.js';
|
|
15
|
+
import { scrapeFormatSurcharge } from '../tools/scrape/formats.js';
|
|
16
|
+
// Stamped on every usage report so support can tell which client version
|
|
17
|
+
// made a call; it read '3.0.3' from 3.0.3 to 5.6.10 (the website stores it
|
|
18
|
+
// per record from its Phase 1.3 on).
|
|
19
|
+
const PACKAGE_VERSION = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8')).version;
|
|
14
20
|
// D1.4: Elicitation for low-credit warnings (lazy import to avoid circular dep)
|
|
15
21
|
let _ElicitationHelper = null;
|
|
16
22
|
function getElicitationHelper() {
|
|
@@ -368,7 +374,7 @@ class AuthManager {
|
|
|
368
374
|
timestamp: new Date().toISOString(),
|
|
369
375
|
requestId,
|
|
370
376
|
idempotencyKey,
|
|
371
|
-
version:
|
|
377
|
+
version: PACKAGE_VERSION
|
|
372
378
|
};
|
|
373
379
|
|
|
374
380
|
try {
|
|
@@ -493,7 +499,7 @@ class AuthManager {
|
|
|
493
499
|
timestamp: entry.timestamp,
|
|
494
500
|
requestId: entry.requestId,
|
|
495
501
|
idempotencyKey,
|
|
496
|
-
version:
|
|
502
|
+
version: PACKAGE_VERSION
|
|
497
503
|
}),
|
|
498
504
|
signal: AbortSignal.timeout(5000)
|
|
499
505
|
});
|
|
@@ -554,6 +560,7 @@ class AuthManager {
|
|
|
554
560
|
scrape_template: 1,
|
|
555
561
|
list_ollama_models: 1,
|
|
556
562
|
get_batch_results: 1, // retrieval of an already-paid batch job
|
|
563
|
+
read_result: 1, // reads a stored result an earlier call already paid for
|
|
557
564
|
|
|
558
565
|
// 2 credits
|
|
559
566
|
scrape_structured: 2,
|
|
@@ -618,6 +625,14 @@ class AuthManager {
|
|
|
618
625
|
return costs.search_web;
|
|
619
626
|
}
|
|
620
627
|
|
|
628
|
+
// The query-scoped formats (highlights, question) add 1 once per call,
|
|
629
|
+
// and mode:"model" adds 3 once; the base price in the table is what the
|
|
630
|
+
// backend sees. The rule lives with the format schemas.
|
|
631
|
+
if (tool === 'scrape') {
|
|
632
|
+
const { query, model } = scrapeFormatSurcharge(params?.formats);
|
|
633
|
+
return costs.scrape + query + model;
|
|
634
|
+
}
|
|
635
|
+
|
|
621
636
|
return costs[tool] ?? 1;
|
|
622
637
|
}
|
|
623
638
|
|
|
@@ -674,7 +689,7 @@ class AuthManager {
|
|
|
674
689
|
break;
|
|
675
690
|
case 'scrape': {
|
|
676
691
|
projected = base;
|
|
677
|
-
note = '
|
|
692
|
+
note = 'Base 2; a highlights or question format adds 1 once per call, mode:"model" adds 3 once (charged at the extractive price when no LLM route exists). json format may incur external LLM cost (billed by your provider).';
|
|
678
693
|
break;
|
|
679
694
|
}
|
|
680
695
|
case 'agent': {
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ResultStore — where a tool result lives once it is too large to return
|
|
3
|
+
* inline (Phase 2, result handles).
|
|
4
|
+
*
|
|
5
|
+
* Local and per-process: the store is on the machine the server runs on,
|
|
6
|
+
* which is where the result already was. Nothing leaves it. Entries are
|
|
7
|
+
* written under ~/.crawlforge/results/ (same root as SnapshotManager's
|
|
8
|
+
* snapshots), indexed in memory, evicted least-recently-used once the total
|
|
9
|
+
* passes maxBytes, and dropped after ttlMs whatever happens. A result that
|
|
10
|
+
* cannot be written to disk stays in memory instead — put never fails for a
|
|
11
|
+
* disk error.
|
|
12
|
+
*
|
|
13
|
+
* Handles are `res_<uuid>` (or a caller-supplied key such as a batch id).
|
|
14
|
+
* RESULT_HANDLE_PATTERN is the path-traversal guard: every method rejects a
|
|
15
|
+
* handle that does not match before touching the filesystem.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import fs from 'node:fs';
|
|
19
|
+
import os from 'node:os';
|
|
20
|
+
import path from 'node:path';
|
|
21
|
+
import { randomUUID } from 'node:crypto';
|
|
22
|
+
|
|
23
|
+
export const RESULT_HANDLE_PATTERN = /^(res|batch)_[A-Za-z0-9_-]{1,80}$/;
|
|
24
|
+
const KEY_PATTERN = /^[A-Za-z0-9_-]{4,80}$/;
|
|
25
|
+
|
|
26
|
+
export class ResultStore {
|
|
27
|
+
/**
|
|
28
|
+
* @param {object} [options]
|
|
29
|
+
* @param {string} [options.baseDir] — directory for the result files (default ~/.crawlforge/results)
|
|
30
|
+
* @param {number} [options.maxBytes] — LRU budget in serialised bytes (default 200 MB)
|
|
31
|
+
* @param {number} [options.ttlMs] — how long an entry is readable (default 1 hour)
|
|
32
|
+
* @param {number} [options.sweepIntervalMs] — expiry sweep period (default 5 minutes)
|
|
33
|
+
* @param {object} [options.logger] — optional logger with warn()
|
|
34
|
+
*/
|
|
35
|
+
constructor({
|
|
36
|
+
baseDir,
|
|
37
|
+
maxBytes = 200 * 1024 * 1024,
|
|
38
|
+
ttlMs = 60 * 60 * 1000,
|
|
39
|
+
sweepIntervalMs = 5 * 60 * 1000,
|
|
40
|
+
logger = null
|
|
41
|
+
} = {}) {
|
|
42
|
+
this.baseDir = baseDir || path.join(os.homedir(), '.crawlforge', 'results');
|
|
43
|
+
this.maxBytes = maxBytes;
|
|
44
|
+
this.ttlMs = ttlMs;
|
|
45
|
+
this.logger = logger;
|
|
46
|
+
// Insertion order is LRU order: get() re-inserts the entry it read.
|
|
47
|
+
this.index = new Map();
|
|
48
|
+
this.totalBytes = 0;
|
|
49
|
+
|
|
50
|
+
this._cleanupStaleFiles();
|
|
51
|
+
|
|
52
|
+
// .unref() so this timer never blocks process exit on its own — matches
|
|
53
|
+
// SnapshotManager's cleanupTimer.
|
|
54
|
+
this._sweepTimer = setInterval(() => this.sweep(), sweepIntervalMs);
|
|
55
|
+
if (typeof this._sweepTimer.unref === 'function') this._sweepTimer.unref();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Store a payload. Never throws for a disk error.
|
|
60
|
+
* @param {string} toolName
|
|
61
|
+
* @param {unknown} payload — any JSON-serialisable value
|
|
62
|
+
* @param {{ key?: string, meta?: object }} [options] — key: caller-supplied handle
|
|
63
|
+
* @returns {string} handle
|
|
64
|
+
*/
|
|
65
|
+
put(toolName, payload, { key, meta = {} } = {}) {
|
|
66
|
+
let handle;
|
|
67
|
+
if (key !== undefined) {
|
|
68
|
+
if (typeof key !== 'string' || !KEY_PATTERN.test(key)) {
|
|
69
|
+
throw new Error(`ResultStore: invalid key "${key}"`);
|
|
70
|
+
}
|
|
71
|
+
if (!RESULT_HANDLE_PATTERN.test(key)) {
|
|
72
|
+
throw new Error(`ResultStore: key "${key}" must start with res_ or batch_ to be readable as a handle`);
|
|
73
|
+
}
|
|
74
|
+
handle = key;
|
|
75
|
+
} else {
|
|
76
|
+
handle = `res_${randomUUID()}`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const json = JSON.stringify(payload);
|
|
80
|
+
const bytes = Buffer.byteLength(json);
|
|
81
|
+
const createdAt = Date.now();
|
|
82
|
+
const entry = { handle, toolName, bytes, createdAt, expiresAt: createdAt + this.ttlMs, meta };
|
|
83
|
+
|
|
84
|
+
// Re-put under the same key replaces the old entry (and its bytes).
|
|
85
|
+
if (this.index.has(handle)) this.delete(handle);
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
fs.mkdirSync(this.baseDir, { recursive: true });
|
|
89
|
+
fs.writeFileSync(this._file(handle), json);
|
|
90
|
+
} catch (error) {
|
|
91
|
+
entry.inMemory = true;
|
|
92
|
+
entry.json = json;
|
|
93
|
+
this.logger?.warn?.(`ResultStore: could not write ${handle} to ${this.baseDir}, keeping it in memory: ${error.message}`);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
this.index.set(handle, entry);
|
|
97
|
+
this.totalBytes += bytes;
|
|
98
|
+
this._evict(handle);
|
|
99
|
+
return handle;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* @param {string} handle
|
|
104
|
+
* @returns {{ handle: string, toolName: string, payload: unknown, bytes: number, createdAt: number, expiresAt: number, meta: object } | null}
|
|
105
|
+
*/
|
|
106
|
+
get(handle) {
|
|
107
|
+
const entry = this._live(handle);
|
|
108
|
+
if (!entry) return null;
|
|
109
|
+
|
|
110
|
+
let payload;
|
|
111
|
+
try {
|
|
112
|
+
const json = entry.inMemory ? entry.json : fs.readFileSync(this._file(handle), 'utf8');
|
|
113
|
+
payload = JSON.parse(json);
|
|
114
|
+
} catch {
|
|
115
|
+
// A missing or corrupt file is treated as gone, never thrown.
|
|
116
|
+
this.delete(handle);
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Refresh recency.
|
|
121
|
+
this.index.delete(handle);
|
|
122
|
+
this.index.set(handle, entry);
|
|
123
|
+
|
|
124
|
+
const { toolName, bytes, createdAt, expiresAt, meta } = entry;
|
|
125
|
+
return { handle, toolName, payload, bytes, createdAt, expiresAt, meta };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Live and unexpired, without a disk read. */
|
|
129
|
+
has(handle) {
|
|
130
|
+
return this._live(handle) !== null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
delete(handle) {
|
|
134
|
+
if (!RESULT_HANDLE_PATTERN.test(handle)) return false;
|
|
135
|
+
const entry = this.index.get(handle);
|
|
136
|
+
if (!entry) return false;
|
|
137
|
+
this.index.delete(handle);
|
|
138
|
+
this.totalBytes -= entry.bytes;
|
|
139
|
+
if (!entry.inMemory) {
|
|
140
|
+
try { fs.unlinkSync(this._file(handle)); } catch { /* already gone */ }
|
|
141
|
+
}
|
|
142
|
+
return true;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Index entries without payloads. */
|
|
146
|
+
list() {
|
|
147
|
+
return [...this.index.values()].map(({ handle, toolName, bytes, createdAt, expiresAt, meta, inMemory }) => (
|
|
148
|
+
{ handle, toolName, bytes, createdAt, expiresAt, meta, inMemory: inMemory === true }
|
|
149
|
+
));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Remove expired entries and their files. */
|
|
153
|
+
sweep() {
|
|
154
|
+
const now = Date.now();
|
|
155
|
+
for (const [handle, entry] of this.index) {
|
|
156
|
+
if (entry.expiresAt <= now) this.delete(handle);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
close() {
|
|
161
|
+
if (this._sweepTimer) {
|
|
162
|
+
clearInterval(this._sweepTimer);
|
|
163
|
+
this._sweepTimer = null;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ── Private ────────────────────────────────────────────────────────────────
|
|
168
|
+
|
|
169
|
+
_file(handle) {
|
|
170
|
+
return path.join(this.baseDir, `${handle}.json`);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** The index entry when the handle is valid, known and unexpired; else null. */
|
|
174
|
+
_live(handle) {
|
|
175
|
+
if (typeof handle !== 'string' || !RESULT_HANDLE_PATTERN.test(handle)) return null;
|
|
176
|
+
const entry = this.index.get(handle);
|
|
177
|
+
if (!entry) return null;
|
|
178
|
+
if (entry.expiresAt <= Date.now()) {
|
|
179
|
+
this.delete(handle);
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
return entry;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Evict least-recently-used entries until the budget holds; never `keep`. */
|
|
186
|
+
_evict(keep) {
|
|
187
|
+
for (const handle of this.index.keys()) {
|
|
188
|
+
if (this.totalBytes <= this.maxBytes) return;
|
|
189
|
+
if (handle === keep) return;
|
|
190
|
+
this.delete(handle);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Best-effort removal of result files older than the TTL. Other server
|
|
196
|
+
* processes may share the directory, so a file younger than the TTL is
|
|
197
|
+
* never touched here even though this process did not write it.
|
|
198
|
+
*/
|
|
199
|
+
_cleanupStaleFiles() {
|
|
200
|
+
try {
|
|
201
|
+
const cutoff = Date.now() - this.ttlMs;
|
|
202
|
+
for (const name of fs.readdirSync(this.baseDir)) {
|
|
203
|
+
if (!name.endsWith('.json') || !RESULT_HANDLE_PATTERN.test(name.slice(0, -5))) continue;
|
|
204
|
+
const file = path.join(this.baseDir, name);
|
|
205
|
+
try {
|
|
206
|
+
if (fs.statSync(file).mtimeMs < cutoff) fs.unlinkSync(file);
|
|
207
|
+
} catch { /* raced with another process */ }
|
|
208
|
+
}
|
|
209
|
+
} catch { /* no directory yet, or unreadable — nothing to clean */ }
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ── Process singleton ────────────────────────────────────────────────────────
|
|
214
|
+
|
|
215
|
+
let singleton = null;
|
|
216
|
+
|
|
217
|
+
/** The process-wide store; CRAWLFORGE_RESULTS_DIR overrides its directory. */
|
|
218
|
+
export function getResultStore() {
|
|
219
|
+
if (!singleton) {
|
|
220
|
+
singleton = new ResultStore({ baseDir: process.env.CRAWLFORGE_RESULTS_DIR || undefined });
|
|
221
|
+
}
|
|
222
|
+
return singleton;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Test seam: replace (or with null, reset) the singleton. */
|
|
226
|
+
export function setResultStoreForTests(store) {
|
|
227
|
+
singleton = store;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export default ResultStore;
|