crawlforge-mcp-server 5.6.11 → 5.9.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 +59 -18
- package/src/core/AuthManager.js +16 -1
- package/src/core/ResultStore.js +230 -0
- package/src/schemas/toolOutputSchemas.js +89 -13
- package/src/server/fallbackHints.js +35 -9
- package/src/server/inlineThreshold.js +149 -0
- package/src/server/toolFilter.js +11 -4
- package/src/server/withAuth.js +25 -0
- package/src/skills/agent-skills/crawlforge-batch-automation/SKILL.md +2 -1
- package/src/skills/agent-skills/crawlforge-getting-started/SKILL.md +6 -4
- package/src/skills/agent-skills/crawlforge-getting-started/references/credits.md +8 -1
- package/src/skills/agent-skills/crawlforge-stealth-browsing/SKILL.md +5 -0
- package/src/skills/agent-skills/crawlforge-web-scraping/SKILL.md +73 -2
- 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/result/readResult.js +147 -0
- package/src/tools/scrape/_highlights.js +79 -0
- package/src/tools/scrape/escalation.js +62 -0
- package/src/tools/scrape/formats.js +47 -1
- package/src/tools/scrape/unifiedScrape.js +345 -55
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. `escalate:true` retries a blocked plain fetch once in the stealth browser and returns the page instead of the block (+5 as the projected ceiling; the charge drops back to 2 when the plain fetch succeeded and no escalation ran) |
|
|
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.9.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
|
@@ -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.9.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"] }],
|
|
@@ -119,12 +121,13 @@ const server = new McpServer({
|
|
|
119
121
|
"- Read one page whose URL you have -> scrape (2). Ask for every format you need in that call: markdown, links, metadata, html, screenshot, json. Default for any page.",
|
|
120
122
|
"- Raw JSON/XML/API body, headers or status -> fetch_url (1). Not for HTML you will read: scrape returns markdown from the same fetch.",
|
|
121
123
|
"- Find pages for a query -> search_web (5); snippets often answer without a scrape. Google organic position -> serp_rank (5). Reddit -> reddit_search (5); reddit.com blocks direct scraping.",
|
|
122
|
-
"- Blocked (403/429/CAPTCHA/challenge page/empty shell) -> stealth_mode operation:\"scrape\" (5). Needs a click, login or scroll -> scrape_with_actions (5). Never start with these.",
|
|
124
|
+
"- Blocked (403/429/CAPTCHA/challenge page/empty shell) -> stealth_mode operation:\"scrape\" (5). scrape with escalate:true when a site is known to block (projected 7, charged 2 when the plain fetch works); still never stealth_mode first. Needs a click, login or scroll -> scrape_with_actions (5). Never start with these.",
|
|
123
125
|
"- 2-50 known URLs -> one batch_scrape (5), never a loop of scrape calls.",
|
|
124
126
|
"- A site's URL list -> map_site (2); many pages of one site -> crawl_deep (4).",
|
|
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" +
|
|
@@ -162,6 +165,7 @@ server.registerPrompt("getting-started", {
|
|
|
162
165
|
"\n" +
|
|
163
166
|
"Hard pages\n" +
|
|
164
167
|
"- stealth_mode (5): after a 403/429/CAPTCHA/challenge page or an empty shell - never first.\n" +
|
|
168
|
+
"- scrape with escalate:true (projected 7): when a site is known to block, one call that reads the page and only falls back to the stealth browser if the plain fetch is walled - charged 2 when it is not. Still never stealth_mode first.\n" +
|
|
165
169
|
"- scrape_with_actions (5): click, log in, scroll or wait, then scrape.\n" +
|
|
166
170
|
"- localization (2): country and locale context for geo-specific content.\n" +
|
|
167
171
|
"\n" +
|
|
@@ -176,6 +180,9 @@ server.registerPrompt("getting-started", {
|
|
|
176
180
|
"- deep_research (10): multi-source report. agent (8): open question, no URLs.\n" +
|
|
177
181
|
"- track_changes (3): create_baseline, then compare. generate_llms_txt (5): write a site's llms.txt.\n" +
|
|
178
182
|
"\n" +
|
|
183
|
+
"Large results\n" +
|
|
184
|
+
"- 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" +
|
|
185
|
+
"\n" +
|
|
179
186
|
"Error results end with \"Next step:\" naming the tool to try - follow it rather than retrying the same call.\n" +
|
|
180
187
|
"\n" +
|
|
181
188
|
"Get your API key at https://www.crawlforge.dev/signup (1,000 free credits)"
|
|
@@ -230,7 +237,22 @@ const deepResearchTool = new DeepResearchTool();
|
|
|
230
237
|
const trackChangesTool = new TrackChangesTool();
|
|
231
238
|
const generateLLMsTxtTool = new GenerateLLMsTxtTool();
|
|
232
239
|
const scrapeTemplateTool = new ScrapeTemplateTool(); // D3.3
|
|
233
|
-
const unifiedScrapeTool = new UnifiedScrapeTool({
|
|
240
|
+
const unifiedScrapeTool = new UnifiedScrapeTool({
|
|
241
|
+
actionExecutor: scrapeWithActionsTool.actionExecutor, // D4 D1 (+v4.8 screenshot reuses the shared browser pool)
|
|
242
|
+
// Phase 3: the escalation stage for `scrape`'s escalate:true, injected so
|
|
243
|
+
// the tool module never imports StealthBrowserManager (that would pull a
|
|
244
|
+
// browser dependency into every unit test that loads `scrape`). Same gate
|
|
245
|
+
// and same browser the stealth_mode tool drives — no new evasion, and the
|
|
246
|
+
// "playwright" → chromium engine mapping stays here with its sibling.
|
|
247
|
+
escalateScrape: async ({ url, engine, respectRobots }) => {
|
|
248
|
+
const warnings = await stealthComplianceGate(url, respectRobots);
|
|
249
|
+
const scraped = await stealthBrowserManager.scrapeWithStealth({
|
|
250
|
+
url,
|
|
251
|
+
engine: engine === 'camoufox' ? 'camoufox' : 'chromium'
|
|
252
|
+
});
|
|
253
|
+
return { ...scraped, engine, warnings };
|
|
254
|
+
}
|
|
255
|
+
});
|
|
234
256
|
const agentTool = new AgentTool(); // D4 D2
|
|
235
257
|
const stealthBrowserManager = new StealthBrowserManager();
|
|
236
258
|
const localizationManager = new LocalizationManager();
|
|
@@ -256,6 +278,7 @@ agentTool.setMcpServer(server); // D4 D2: SamplingClient + Elicitation
|
|
|
256
278
|
trackChangesTool.setMcpServer(server); // v4.8: SamplingClient for scheduled-monitor goal judging
|
|
257
279
|
extractWithLlmTool.setMcpServer(server); // SamplingClient fallback
|
|
258
280
|
summarizeContentTool.setMcpServer(server); // SamplingClient fallback
|
|
281
|
+
unifiedScrapeTool.setMcpServer(server); // SamplingClient for the highlights/question mode:"model" step
|
|
259
282
|
AuthManager.setElicitation(elicitation);
|
|
260
283
|
|
|
261
284
|
// ─── D1.1 Resource Templates (MCP Resources) ─────────────────────────────────
|
|
@@ -354,7 +377,8 @@ registerToolIfEnabled("fetch_url", {
|
|
|
354
377
|
url: z.string().url().describe("The URL to fetch content from"),
|
|
355
378
|
headers: z.record(z.string()).optional().describe("Custom HTTP headers to include in the request"),
|
|
356
379
|
timeout: z.number().min(1000).max(30000).optional().default(10000).describe("Request timeout in milliseconds (1000-30000)"),
|
|
357
|
-
...COMPLIANCE_PARAMS
|
|
380
|
+
...COMPLIANCE_PARAMS,
|
|
381
|
+
...MAX_INLINE_CHARS_PARAM
|
|
358
382
|
}
|
|
359
383
|
}, withAuth("fetch_url", fetchUrlHandler));
|
|
360
384
|
|
|
@@ -401,7 +425,8 @@ registerToolIfEnabled("extract_embedded_state", {
|
|
|
401
425
|
inputSchema: {
|
|
402
426
|
url: z.string().url().describe("The URL to read embedded state from"),
|
|
403
427
|
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
|
|
428
|
+
...COMPLIANCE_PARAMS,
|
|
429
|
+
...MAX_INLINE_CHARS_PARAM
|
|
405
430
|
}
|
|
406
431
|
}, withAuth("extract_embedded_state", extractEmbeddedStateHandler));
|
|
407
432
|
|
|
@@ -574,7 +599,8 @@ if (toolFilter.isEnabled("crawl_deep")) {
|
|
|
574
599
|
headers: z.record(z.string()).optional(),
|
|
575
600
|
body: z.string().optional()
|
|
576
601
|
}).optional()
|
|
577
|
-
}).optional().describe("Shared cookie-jar/session for login-then-crawl workflows")
|
|
602
|
+
}).optional().describe("Shared cookie-jar/session for login-then-crawl workflows"),
|
|
603
|
+
...MAX_INLINE_CHARS_PARAM
|
|
578
604
|
},
|
|
579
605
|
outputSchema: OUTPUT_SCHEMAS.crawl_deep,
|
|
580
606
|
execution: TASK_EXECUTION
|
|
@@ -636,7 +662,8 @@ registerToolIfEnabled("extract_content", {
|
|
|
636
662
|
inputSchema: {
|
|
637
663
|
url: z.string().url().describe("The URL to extract content from"),
|
|
638
664
|
options: z.object({}).passthrough().optional().describe("Additional extraction options"),
|
|
639
|
-
...COMPLIANCE_PARAMS
|
|
665
|
+
...COMPLIANCE_PARAMS,
|
|
666
|
+
...MAX_INLINE_CHARS_PARAM
|
|
640
667
|
}
|
|
641
668
|
}, withAuth("extract_content", async (params) => {
|
|
642
669
|
try {
|
|
@@ -660,7 +687,8 @@ registerToolIfEnabled("process_document", {
|
|
|
660
687
|
// C3: passthrough so granular options (maxPages, pageRange:{start,end},
|
|
661
688
|
// extractText, outputFormat, etc.) reach the tool instead of being stripped.
|
|
662
689
|
options: z.object({}).passthrough().optional().describe("Additional processing options (maxPages, pageRange:{start,end}, extractText, extractMetadata, outputFormat, ...)"),
|
|
663
|
-
...COMPLIANCE_PARAMS
|
|
690
|
+
...COMPLIANCE_PARAMS,
|
|
691
|
+
...MAX_INLINE_CHARS_PARAM
|
|
664
692
|
}
|
|
665
693
|
}, withAuth("process_document", async (params) => {
|
|
666
694
|
try {
|
|
@@ -825,7 +853,8 @@ if (toolFilter.isEnabled("batch_scrape")) {
|
|
|
825
853
|
maxRetries: z.number().min(0).max(5).default(1),
|
|
826
854
|
tags: z.array(z.string()).default([])
|
|
827
855
|
}).optional().describe("Job management options for async processing"),
|
|
828
|
-
...COMPLIANCE_PARAMS
|
|
856
|
+
...COMPLIANCE_PARAMS,
|
|
857
|
+
...MAX_INLINE_CHARS_PARAM
|
|
829
858
|
},
|
|
830
859
|
execution: TASK_EXECUTION
|
|
831
860
|
}, makeTaskToolHandler({
|
|
@@ -864,6 +893,14 @@ registerToolIfEnabled("get_batch_results", {
|
|
|
864
893
|
}
|
|
865
894
|
}));
|
|
866
895
|
|
|
896
|
+
// Tool: read_result — Phase 2: read a stored result by the handle a truncated result returned
|
|
897
|
+
registerToolIfEnabled("read_result", {
|
|
898
|
+
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\"})",
|
|
899
|
+
annotations: { title: "Read Result", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
900
|
+
inputSchema: READ_RESULT_INPUT_SHAPE,
|
|
901
|
+
outputSchema: OUTPUT_SCHEMAS.read_result
|
|
902
|
+
}, withAuth("read_result", readResultHandler));
|
|
903
|
+
|
|
867
904
|
// Tool: scrape_with_actions
|
|
868
905
|
registerToolIfEnabled("scrape_with_actions", {
|
|
869
906
|
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 +983,8 @@ registerToolIfEnabled("scrape_with_actions", {
|
|
|
946
983
|
continueOnActionError: z.boolean().default(false).describe("Continue executing actions if one fails"),
|
|
947
984
|
maxRetries: z.number().min(0).max(3).default(1).describe("Maximum retry attempts on failure"),
|
|
948
985
|
screenshotOnError: z.boolean().default(true).describe("Capture screenshot when an error occurs"),
|
|
949
|
-
respect_robots: COMPLIANCE_PARAMS.respect_robots
|
|
986
|
+
respect_robots: COMPLIANCE_PARAMS.respect_robots,
|
|
987
|
+
...MAX_INLINE_CHARS_PARAM
|
|
950
988
|
}
|
|
951
989
|
}, withAuth("scrape_with_actions", async (params) => {
|
|
952
990
|
try {
|
|
@@ -1023,7 +1061,8 @@ if (toolFilter.isEnabled("deep_research")) {
|
|
|
1023
1061
|
url: z.string().url(),
|
|
1024
1062
|
events: z.array(z.enum(['started', 'progress', 'completed', 'failed'])).optional().default(['completed']),
|
|
1025
1063
|
headers: z.record(z.string()).optional()
|
|
1026
|
-
}).optional().describe("Webhook for progress and completion notifications")
|
|
1064
|
+
}).optional().describe("Webhook for progress and completion notifications"),
|
|
1065
|
+
...MAX_INLINE_CHARS_PARAM
|
|
1027
1066
|
},
|
|
1028
1067
|
execution: TASK_EXECUTION
|
|
1029
1068
|
}, makeTaskToolHandler({
|
|
@@ -1043,7 +1082,7 @@ if (toolFilter.isEnabled("deep_research")) {
|
|
|
1043
1082
|
|
|
1044
1083
|
// Tool: scrape (D4 D1 — unified multi-format single-fetch)
|
|
1045
1084
|
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
|
|
1085
|
+
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\"]})",
|
|
1047
1086
|
annotations: { title: "Scrape (Multi-Format)", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
1048
1087
|
// Claude Code tool search loads only names + instructions at session start; this flag
|
|
1049
1088
|
// ships the full definition too, so the first call needs no ToolSearch round-trip.
|
|
@@ -1051,7 +1090,8 @@ registerToolIfEnabled("scrape", {
|
|
|
1051
1090
|
// The tool module owns the schema (0.3); this is the same shape it validates with.
|
|
1052
1091
|
inputSchema: {
|
|
1053
1092
|
...SCRAPE_INPUT_SHAPE,
|
|
1054
|
-
...COMPLIANCE_PARAMS
|
|
1093
|
+
...COMPLIANCE_PARAMS,
|
|
1094
|
+
...MAX_INLINE_CHARS_PARAM
|
|
1055
1095
|
},
|
|
1056
1096
|
outputSchema: OUTPUT_SCHEMAS.scrape
|
|
1057
1097
|
}, withAuth("scrape", async (params) => {
|
|
@@ -1368,7 +1408,8 @@ registerToolIfEnabled("stealth_mode", {
|
|
|
1368
1408
|
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."),
|
|
1369
1409
|
wait_for: z.number().min(0).max(30000).optional().describe("Extra wait after page load, in ms — for content that renders after DOMContentLoaded"),
|
|
1370
1410
|
verbose: z.boolean().optional().default(false).describe("Return the full generated fingerprint from create_context instead of a summary"),
|
|
1371
|
-
respect_robots: COMPLIANCE_PARAMS.respect_robots
|
|
1411
|
+
respect_robots: COMPLIANCE_PARAMS.respect_robots,
|
|
1412
|
+
...MAX_INLINE_CHARS_PARAM
|
|
1372
1413
|
}
|
|
1373
1414
|
}, withAuth("stealth_mode", async ({ operation, stealthConfig, contextId, urlToTest, url, formats, wait_for, verbose, engine, respect_robots }) => {
|
|
1374
1415
|
try {
|
|
@@ -1717,10 +1758,10 @@ async function runServer() {
|
|
|
1717
1758
|
console.error("Search enabled: true (via CrawlForge proxy)");
|
|
1718
1759
|
|
|
1719
1760
|
const allTools = [
|
|
1720
|
-
"fetch_url", "extract_text", "extract_links", "extract_metadata", "scrape_structured",
|
|
1761
|
+
"fetch_url", "extract_text", "extract_links", "extract_metadata", "scrape_structured", "extract_embedded_state",
|
|
1721
1762
|
"search_web", "serp_rank", "reddit_search", "crawl_deep", "map_site",
|
|
1722
1763
|
"extract_content", "process_document", "summarize_content", "analyze_content",
|
|
1723
|
-
"batch_scrape", "get_batch_results", "scrape_with_actions",
|
|
1764
|
+
"batch_scrape", "get_batch_results", "read_result", "scrape_with_actions",
|
|
1724
1765
|
"deep_research", "track_changes", "generate_llms_txt",
|
|
1725
1766
|
"stealth_mode", "localization", "extract_structured", "extract_with_llm",
|
|
1726
1767
|
"list_ollama_models", "scrape_template", // D3.3
|
package/src/core/AuthManager.js
CHANGED
|
@@ -12,6 +12,8 @@ import { isCreatorModeVerified } from './creatorMode.js';
|
|
|
12
12
|
import { resolveApiEndpoint } from './endpointGuard.js';
|
|
13
13
|
import { logger } from '../utils/Logger.js';
|
|
14
14
|
import { maskSecrets } from '../utils/secretMask.js';
|
|
15
|
+
import { scrapeFormatSurcharge } from '../tools/scrape/formats.js';
|
|
16
|
+
import { scrapeEscalationSurcharge } from '../tools/scrape/escalation.js';
|
|
15
17
|
// Stamped on every usage report so support can tell which client version
|
|
16
18
|
// made a call; it read '3.0.3' from 3.0.3 to 5.6.10 (the website stores it
|
|
17
19
|
// per record from its Phase 1.3 on).
|
|
@@ -559,6 +561,7 @@ class AuthManager {
|
|
|
559
561
|
scrape_template: 1,
|
|
560
562
|
list_ollama_models: 1,
|
|
561
563
|
get_batch_results: 1, // retrieval of an already-paid batch job
|
|
564
|
+
read_result: 1, // reads a stored result an earlier call already paid for
|
|
562
565
|
|
|
563
566
|
// 2 credits
|
|
564
567
|
scrape_structured: 2,
|
|
@@ -623,6 +626,16 @@ class AuthManager {
|
|
|
623
626
|
return costs.search_web;
|
|
624
627
|
}
|
|
625
628
|
|
|
629
|
+
// The query-scoped formats (highlights, question) add 1 once per call,
|
|
630
|
+
// and mode:"model" adds 3 once; escalate:true adds the stealth browser's
|
|
631
|
+
// own 5, since that is the ceiling the call may reach. The base price in
|
|
632
|
+
// the table is what the backend sees. Both rules live with the schemas
|
|
633
|
+
// that declare the params.
|
|
634
|
+
if (tool === 'scrape') {
|
|
635
|
+
const { query, model } = scrapeFormatSurcharge(params?.formats);
|
|
636
|
+
return costs.scrape + query + model + scrapeEscalationSurcharge(params?.escalate);
|
|
637
|
+
}
|
|
638
|
+
|
|
626
639
|
return costs[tool] ?? 1;
|
|
627
640
|
}
|
|
628
641
|
|
|
@@ -679,7 +692,9 @@ class AuthManager {
|
|
|
679
692
|
break;
|
|
680
693
|
case 'scrape': {
|
|
681
694
|
projected = base;
|
|
682
|
-
note = '
|
|
695
|
+
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). ' +
|
|
696
|
+
'escalate:true adds 5 for the stealth browser it may need — the projection is the ceiling, and the actual charge drops back to the base when the plain fetch succeeded and no escalation ran. ' +
|
|
697
|
+
'json format may incur external LLM cost (billed by your provider).';
|
|
683
698
|
break;
|
|
684
699
|
}
|
|
685
700
|
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;
|