crawlforge-mcp-server 5.6.0 → 5.6.1
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 +6 -11
- package/README.md +1 -1
- package/package.json +2 -2
- package/server.js +2 -2
- package/src/core/AgentOrchestrator.js +6 -0
- package/src/core/LocalizationManager.js +6 -2
- package/src/core/StealthBrowserManager.js +85 -34
- package/src/core/llm/LLMManager.js +14 -5
- package/src/skills/agent-skills/crawlforge-deep-research/SKILL.md +6 -4
- package/src/tools/extract/extractStructured.js +38 -5
- package/src/tools/extract/extractWithLlm.js +15 -13
- package/src/tools/scrape/unifiedScrape.js +15 -13
- package/src/tools/search/adapters/redditOfficialApi.js +1 -1
- package/src/tools/search/redditSearch.js +2 -2
- package/src/utils/extractionFormat.js +52 -0
- package/src/utils/provenance.js +38 -2
- package/src/utils/robotsGate.js +24 -1
package/CLAUDE.md
CHANGED
|
@@ -60,9 +60,9 @@ 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 29 web scraping, crawling, and content processing tools (6 inline + 23 advanced).
|
|
64
64
|
|
|
65
|
-
**Current Version:** 5.
|
|
65
|
+
**Current Version:** 5.6.0
|
|
66
66
|
|
|
67
67
|
## Development Commands
|
|
68
68
|
|
|
@@ -75,11 +75,6 @@ npm run setup
|
|
|
75
75
|
# Or provide API key via environment:
|
|
76
76
|
export CRAWLFORGE_API_KEY="your_api_key_here"
|
|
77
77
|
|
|
78
|
-
# Creator Mode (for package maintainer only)
|
|
79
|
-
# Set your creator secret in .env file:
|
|
80
|
-
# CRAWLFORGE_CREATOR_SECRET=your-secret-uuid
|
|
81
|
-
# This enables unlimited access for development/testing
|
|
82
|
-
|
|
83
78
|
# Run the server (production)
|
|
84
79
|
npm start
|
|
85
80
|
|
|
@@ -172,13 +167,13 @@ Tools are organized in subdirectories by category:
|
|
|
172
167
|
- `tracking/` - trackChanges
|
|
173
168
|
- `llmstxt/` - generateLLMsTxt
|
|
174
169
|
|
|
175
|
-
### Available MCP Tools (
|
|
170
|
+
### Available MCP Tools (29 total)
|
|
176
171
|
|
|
177
|
-
**Basic Tools (server.js inline,
|
|
178
|
-
fetch_url, extract_text, extract_links, extract_metadata, scrape_structured
|
|
172
|
+
**Basic Tools (server.js inline, 6):**
|
|
173
|
+
fetch_url, extract_text, extract_links, extract_metadata, extract_embedded_state, scrape_structured
|
|
179
174
|
|
|
180
175
|
**Advanced Tools (23):**
|
|
181
|
-
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, scrape_with_actions, deep_research, track_changes, generate_llms_txt, stealth_mode, localization, scrape_template, scrape, agent
|
|
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
|
|
182
177
|
|
|
183
178
|
**serp_rank (DataForSEO):**
|
|
184
179
|
- `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
|
@@ -192,7 +192,7 @@ CrawlForge requires a CrawlForge API key — **every tool is metered and consume
|
|
|
192
192
|
| `summarize_content` | 4 | Generate intelligent summaries |
|
|
193
193
|
| `crawl_deep` | 4 | Deep crawl entire websites |
|
|
194
194
|
| `search_web` | 5 | Search the web using Google Search API |
|
|
195
|
-
| `reddit_search` | 5 | Search Reddit posts/comments or read a full thread — reddit.com blocks direct scraping, so this
|
|
195
|
+
| `reddit_search` | 5 | Search Reddit posts/comments or read a full thread — reddit.com blocks direct scraping, so this reads the Arctic Shift community archive (free, no Reddit credentials). A Reddit-wide search spends a web search to discover posts, so it is priced with `search_web` |
|
|
196
196
|
| `serp_rank` | 5 | Check where a domain ranks in Google's **real organic SERP** for a keyword (the position `search_web` can't give). Powered by DataForSEO (`DATAFORSEO_LOGIN`/`DATAFORSEO_PASSWORD`, billed to your own DataForSEO account). Returns `{ configured:false }` and charges **0** credits until configured |
|
|
197
197
|
| `batch_scrape` | 5 | Process multiple URLs simultaneously |
|
|
198
198
|
| `scrape_with_actions` | 5 | Browser automation chains |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "crawlforge-mcp-server",
|
|
3
|
-
"version": "5.6.
|
|
3
|
+
"version": "5.6.1",
|
|
4
4
|
"mcpName": "io.github.mysleekdesigns/crawlforge-mcp-server",
|
|
5
5
|
"description": "CrawlForge MCP Server - Professional Model Context Protocol server with 29 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",
|
|
@@ -114,7 +114,7 @@
|
|
|
114
114
|
"cheerio": "^1.1.2",
|
|
115
115
|
"commander": "^14.0.3",
|
|
116
116
|
"compromise": "^14.14.4",
|
|
117
|
-
"crawlforge-extractors": "^1.6.
|
|
117
|
+
"crawlforge-extractors": "^1.6.1",
|
|
118
118
|
"diff": "^9.0.0",
|
|
119
119
|
"dotenv": "^17.2.1",
|
|
120
120
|
"franc": "^6.2.0",
|
package/server.js
CHANGED
|
@@ -105,7 +105,7 @@ const taskStore = createTaskStore({ logger });
|
|
|
105
105
|
// Create the server
|
|
106
106
|
const server = new McpServer({
|
|
107
107
|
name: "crawlforge",
|
|
108
|
-
version: "5.6.
|
|
108
|
+
version: "5.6.1",
|
|
109
109
|
description: "Production-ready MCP server with 29 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.",
|
|
110
110
|
homepage: "https://www.crawlforge.dev",
|
|
111
111
|
icon: "https://www.crawlforge.dev/icon.png",
|
|
@@ -1574,7 +1574,7 @@ registerToolIfEnabled("localization", {
|
|
|
1574
1574
|
break;
|
|
1575
1575
|
case 'generate_timezone_spoof':
|
|
1576
1576
|
result = {
|
|
1577
|
-
timezoneScript: await localizationManager.generateTimezoneSpoof(params.countryCode),
|
|
1577
|
+
timezoneScript: await localizationManager.generateTimezoneSpoof(params.countryCode, params.timezone),
|
|
1578
1578
|
countryCode: params.countryCode || localizationManager.getCurrentSettings().countryCode
|
|
1579
1579
|
};
|
|
1580
1580
|
break;
|
|
@@ -203,7 +203,13 @@ export class AgentOrchestrator {
|
|
|
203
203
|
const { text } = await this._getSamplingClient().complete(planPrompt, { maxTokens: 200 });
|
|
204
204
|
const lines = text.split('\n')
|
|
205
205
|
.map(l => l.replace(/^[-*\d.)\s]+/, '').trim())
|
|
206
|
+
// A model that fences its list ("```\nq1\nq2\n```") or quotes each
|
|
207
|
+
// query leaks the fence and quote characters into the queries; the
|
|
208
|
+
// R14 Julia run spent two of its three searches on the literal "```".
|
|
209
|
+
.map(l => l.replace(/^```\w*\s*|\s*```$/g, '').replace(/^[`"']+|[`"']+$/g, '').trim())
|
|
206
210
|
.filter(Boolean)
|
|
211
|
+
// A line with no letter or digit left is punctuation, not a query.
|
|
212
|
+
.filter(l => /[\p{L}\p{N}]/u.test(l))
|
|
207
213
|
// Drop preamble/garbage lines ("Here are 3 concise web search queries:", …)
|
|
208
214
|
// so they never become search query #1 and poison the URL queue.
|
|
209
215
|
.filter(l =>
|
|
@@ -428,9 +428,13 @@ export class LocalizationManager extends EventEmitter {
|
|
|
428
428
|
* @param {string} countryCode - Target country code
|
|
429
429
|
* @returns {string} - JavaScript injection code
|
|
430
430
|
*/
|
|
431
|
-
async generateTimezoneSpoof(countryCode = null) {
|
|
431
|
+
async generateTimezoneSpoof(countryCode = null, timezone = null) {
|
|
432
432
|
const targetCountry = countryCode || this.currentSettings.countryCode;
|
|
433
|
-
|
|
433
|
+
let config = await this.getLocalizationConfig(targetCountry);
|
|
434
|
+
// An explicit IANA timezone wins over the country default: the tool
|
|
435
|
+
// accepted `timezone` and silently answered with the configured country's
|
|
436
|
+
// zone (Asia/Tokyo asked, Europe/Berlin returned — R14).
|
|
437
|
+
if (timezone) config = { ...config, timezone };
|
|
434
438
|
|
|
435
439
|
const timezoneOffset = this.getTimezoneOffset(config.timezone);
|
|
436
440
|
|
|
@@ -616,6 +616,14 @@ export class StealthBrowserManager {
|
|
|
616
616
|
|
|
617
617
|
// Select browser based on distribution and OS compatibility
|
|
618
618
|
let availableBrowsers = { ...this.browserDistribution };
|
|
619
|
+
// The engine decides the pool. Chromium exposes navigator.vendor
|
|
620
|
+
// "Google Inc.", window.chrome and Chrome's PDF viewer plugins, so a
|
|
621
|
+
// Firefox or Safari User-Agent on it contradicts what the page can see —
|
|
622
|
+
// sannysoft printed a Firefox UA beside "Chrome: present" (R14). Camoufox
|
|
623
|
+
// presents its own Firefox identity and never draws from this pool.
|
|
624
|
+
if (config.engine !== 'camoufox') {
|
|
625
|
+
availableBrowsers = { chrome: 1 };
|
|
626
|
+
}
|
|
619
627
|
if (selectedOS === 'linux' && availableBrowsers.safari) {
|
|
620
628
|
delete availableBrowsers.safari;
|
|
621
629
|
// Redistribute safari's weight
|
|
@@ -1005,7 +1013,9 @@ export class StealthBrowserManager {
|
|
|
1005
1013
|
processor: selectedProcessor.name,
|
|
1006
1014
|
architecture: 'x86_64',
|
|
1007
1015
|
memory: Math.floor(Math.random() * 24) + 8, // 8-32 GB
|
|
1008
|
-
|
|
1016
|
+
// navigator.deviceMemory is capped at 8 in every Chromium build, so 16
|
|
1017
|
+
// or 32 is a value no real browser reports (sannysoft CHR_MEMORY: FAIL).
|
|
1018
|
+
deviceMemory: Math.random() < 0.7 ? 8 : 4,
|
|
1009
1019
|
platform: this.selectRealisticPlatform(selectedOS)
|
|
1010
1020
|
};
|
|
1011
1021
|
}
|
|
@@ -1215,29 +1225,64 @@ export class StealthBrowserManager {
|
|
|
1215
1225
|
})
|
|
1216
1226
|
});
|
|
1217
1227
|
|
|
1218
|
-
//
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1228
|
+
// Plugin list. New headless Chromium already reports the same five PDF
|
|
1229
|
+
// viewer entries as a real PluginArray, and replacing them with a plain
|
|
1230
|
+
// Array fails the "plugins is of type PluginArray" check every scanner
|
|
1231
|
+
// runs. Only an empty list — the legacy headless tell — is replaced.
|
|
1232
|
+
if (navigator.plugins.length === 0 && typeof PluginArray !== 'undefined') {
|
|
1233
|
+
// Built on the real prototypes so `instanceof PluginArray`, the
|
|
1234
|
+
// toStringTag and the DOM-style accessors all hold — a plain Array here
|
|
1235
|
+
// failed bot.sannysoft.com's "plugins is of type PluginArray" check.
|
|
1236
|
+
// The five entries are what every desktop Chrome reports.
|
|
1237
|
+
const mimeSpecs = [
|
|
1238
|
+
{ type: 'application/pdf', suffixes: 'pdf', description: 'Portable Document Format' },
|
|
1239
|
+
{ type: 'text/pdf', suffixes: 'pdf', description: 'Portable Document Format' }
|
|
1240
|
+
];
|
|
1241
|
+
const makePlugin = (name) => {
|
|
1242
|
+
const plugin = Object.create(Plugin.prototype);
|
|
1243
|
+
const mimeTypes = mimeSpecs.map((spec) => {
|
|
1244
|
+
const mimeType = Object.create(MimeType.prototype);
|
|
1245
|
+
Object.defineProperties(mimeType, {
|
|
1246
|
+
type: { value: spec.type },
|
|
1247
|
+
suffixes: { value: spec.suffixes },
|
|
1248
|
+
description: { value: spec.description },
|
|
1249
|
+
enabledPlugin: { get: () => plugin }
|
|
1250
|
+
});
|
|
1251
|
+
return mimeType;
|
|
1252
|
+
});
|
|
1253
|
+
Object.defineProperties(plugin, {
|
|
1254
|
+
name: { value: name },
|
|
1255
|
+
filename: { value: 'internal-pdf-viewer' },
|
|
1256
|
+
description: { value: 'Portable Document Format' },
|
|
1257
|
+
length: { value: mimeTypes.length },
|
|
1258
|
+
item: { value: (index) => mimeTypes[index] || null },
|
|
1259
|
+
namedItem: { value: (type) => mimeTypes.find((m) => m.type === type) || null },
|
|
1260
|
+
[Symbol.iterator]: { value: function* () { yield* mimeTypes; } }
|
|
1261
|
+
});
|
|
1262
|
+
mimeTypes.forEach((mimeType, index) => {
|
|
1263
|
+
Object.defineProperty(plugin, index, { value: mimeType, enumerable: true });
|
|
1264
|
+
Object.defineProperty(plugin, mimeType.type, { value: mimeType });
|
|
1265
|
+
});
|
|
1266
|
+
return plugin;
|
|
1267
|
+
};
|
|
1268
|
+
const plugins = [
|
|
1269
|
+
'PDF Viewer', 'Chrome PDF Viewer', 'Chromium PDF Viewer',
|
|
1270
|
+
'Microsoft Edge PDF Viewer', 'WebKit built-in PDF'
|
|
1271
|
+
].map(makePlugin);
|
|
1272
|
+
const pluginArray = Object.create(PluginArray.prototype);
|
|
1273
|
+
Object.defineProperties(pluginArray, {
|
|
1274
|
+
length: { value: plugins.length },
|
|
1275
|
+
item: { value: (index) => plugins[index] || null },
|
|
1276
|
+
namedItem: { value: (name) => plugins.find((p) => p.name === name) || null },
|
|
1277
|
+
refresh: { value: () => {} },
|
|
1278
|
+
[Symbol.iterator]: { value: function* () { yield* plugins; } }
|
|
1279
|
+
});
|
|
1280
|
+
plugins.forEach((plugin, index) => {
|
|
1281
|
+
Object.defineProperty(pluginArray, index, { value: plugin, enumerable: true });
|
|
1282
|
+
Object.defineProperty(pluginArray, plugin.name, { value: plugin });
|
|
1283
|
+
});
|
|
1284
|
+
Object.defineProperty(navigator, 'plugins', { get: () => pluginArray });
|
|
1285
|
+
}
|
|
1241
1286
|
|
|
1242
1287
|
// Override languages with the fingerprint's own locale — a hardcoded
|
|
1243
1288
|
// en-US here contradicts navigator.language and Accept-Language whenever
|
|
@@ -1367,14 +1412,23 @@ export class StealthBrowserManager {
|
|
|
1367
1412
|
HTMLCanvasElement.prototype.getContext = function(contextType, contextAttributes) {
|
|
1368
1413
|
const ctx = getContext.call(this, contextType, contextAttributes);
|
|
1369
1414
|
|
|
1370
|
-
|
|
1415
|
+
// Headless Chromium without a GPU path answers getContext('webgl')
|
|
1416
|
+
// with null; wrapping it threw a TypeError where a real browser
|
|
1417
|
+
// returns null, which is itself a tell.
|
|
1418
|
+
if (ctx && (contextType === 'webgl' || contextType === 'experimental-webgl')) {
|
|
1371
1419
|
const originalGetParameter = ctx.getParameter;
|
|
1420
|
+
// Real Chrome answers VENDOR/RENDERER with "WebKit"/"WebKit WebGL";
|
|
1421
|
+
// the GPU identity lives behind WEBGL_debug_renderer_info as the
|
|
1422
|
+
// UNMASKED_VENDOR_WEBGL (37445) / UNMASKED_RENDERER_WEBGL (37446)
|
|
1423
|
+
// parameters. Those are what a scanner reads, so those are spoofed.
|
|
1424
|
+
const UNMASKED_VENDOR_WEBGL = 37445;
|
|
1425
|
+
const UNMASKED_RENDERER_WEBGL = 37446;
|
|
1372
1426
|
ctx.getParameter = function(parameter) {
|
|
1373
1427
|
// Spoof specific WebGL parameters
|
|
1374
|
-
if (parameter ===
|
|
1428
|
+
if (parameter === UNMASKED_RENDERER_WEBGL) {
|
|
1375
1429
|
return webglConfig.renderer;
|
|
1376
1430
|
}
|
|
1377
|
-
if (parameter ===
|
|
1431
|
+
if (parameter === UNMASKED_VENDOR_WEBGL) {
|
|
1378
1432
|
return webglConfig.vendor;
|
|
1379
1433
|
}
|
|
1380
1434
|
if (parameter === ctx.VERSION) {
|
|
@@ -1387,13 +1441,10 @@ export class StealthBrowserManager {
|
|
|
1387
1441
|
return originalGetParameter.call(this, parameter);
|
|
1388
1442
|
};
|
|
1389
1443
|
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
}
|
|
1395
|
-
return null;
|
|
1396
|
-
};
|
|
1444
|
+
// getExtension is left alone: returning null for an extension the
|
|
1445
|
+
// fingerprint's list did not name made WEBGL_debug_renderer_info
|
|
1446
|
+
// vanish, and a page that reads it threw a TypeError where a real
|
|
1447
|
+
// browser reports a GPU (sannysoft "WebGL Vendor: Error", R14).
|
|
1397
1448
|
}
|
|
1398
1449
|
|
|
1399
1450
|
return ctx;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { OpenAIProvider } from './OpenAIProvider.js';
|
|
2
|
+
import { extractionFormat } from '../../utils/extractionFormat.js';
|
|
2
3
|
import { AnthropicProvider } from './AnthropicProvider.js';
|
|
3
4
|
import { OllamaProvider } from './OllamaProvider.js';
|
|
4
5
|
import { Logger } from '../../utils/Logger.js';
|
|
@@ -919,7 +920,7 @@ Return the list and nothing else.`;
|
|
|
919
920
|
const schemaFields = Object.keys(schema.properties || {}).length;
|
|
920
921
|
const scaledTokens = Math.min(2000, Math.max(maxTokens, schemaFields * 100 + 500));
|
|
921
922
|
|
|
922
|
-
const systemPrompt = `You are a structured data extraction expert. Extract data from the provided content and return ONLY valid JSON that conforms to the given JSON Schema. Do not include any explanation or markdown — only the raw JSON object.`;
|
|
923
|
+
const systemPrompt = `You are a structured data extraction expert. Extract data from the provided content and return ONLY valid JSON that conforms to the given JSON Schema. Use null for any field the content does not state — never guess, infer, or fill a value from memory. Do not include any explanation or markdown — only the raw JSON object.`;
|
|
923
924
|
|
|
924
925
|
const schemaStr = JSON.stringify(schema, null, 2);
|
|
925
926
|
const guidance = userPrompt ? `\n\nExtraction guidance: ${userPrompt}` : '';
|
|
@@ -937,9 +938,11 @@ Extract the data and return valid JSON:`;
|
|
|
937
938
|
systemPrompt,
|
|
938
939
|
maxTokens: scaledTokens,
|
|
939
940
|
temperature: 0.1,
|
|
940
|
-
// Constrain the output to
|
|
941
|
-
//
|
|
942
|
-
|
|
941
|
+
// Constrain the output to the caller's shape with every field
|
|
942
|
+
// nullable, so a model shown content that does not state a field can
|
|
943
|
+
// answer null instead of being decoded into an invented string. Small
|
|
944
|
+
// local models otherwise wrap the JSON in prose and the parse throws.
|
|
945
|
+
format: extractionFormat(schema)
|
|
943
946
|
});
|
|
944
947
|
|
|
945
948
|
// Strip markdown code fences if present
|
|
@@ -972,7 +975,9 @@ Extract the data and return valid JSON:`;
|
|
|
972
975
|
const required = schema.required || [];
|
|
973
976
|
|
|
974
977
|
for (const field of required) {
|
|
975
|
-
|
|
978
|
+
// The decoder is told to answer null for a field the content never
|
|
979
|
+
// states, so a null here is "not filled in", the same as absent.
|
|
980
|
+
if (!(field in data) || data[field] === null) {
|
|
976
981
|
errors.push(`Missing required field: ${field}`);
|
|
977
982
|
}
|
|
978
983
|
}
|
|
@@ -980,6 +985,10 @@ Extract the data and return valid JSON:`;
|
|
|
980
985
|
for (const [key, fieldSchema] of Object.entries(properties)) {
|
|
981
986
|
if (key in data) {
|
|
982
987
|
const value = data[key];
|
|
988
|
+
// A null in a field the schema does not require is the honest answer
|
|
989
|
+
// for content that never states it, not a type error (typeof null is
|
|
990
|
+
// 'object', which used to read as "expected number, got object").
|
|
991
|
+
if (value === null) continue;
|
|
983
992
|
const expectedType = fieldSchema.type;
|
|
984
993
|
if (expectedType) {
|
|
985
994
|
const actualType = Array.isArray(value) ? 'array' : typeof value;
|
|
@@ -53,8 +53,8 @@ Returns titles, URLs, snippets. Supports `lang`, `site` (domain filter),
|
|
|
53
53
|
|
|
54
54
|
## reddit_search (cost: 5)
|
|
55
55
|
|
|
56
|
-
reddit.com 403-blocks direct scraping, so this
|
|
57
|
-
|
|
56
|
+
reddit.com 403-blocks direct scraping, so this reads the Arctic Shift community
|
|
57
|
+
archive instead (free, no Reddit credentials).
|
|
58
58
|
|
|
59
59
|
```json
|
|
60
60
|
{
|
|
@@ -65,8 +65,10 @@ PullPush community archives instead (free, no Reddit credentials).
|
|
|
65
65
|
|
|
66
66
|
Modes: `posts` (default), `comments`, and `thread` (a post plus its nested
|
|
67
67
|
comment tree via `link_id`). Scope with `subreddit`/`author` for near-real-time
|
|
68
|
-
results (Arctic Shift); an unscoped `query` searches all of Reddit via
|
|
69
|
-
|
|
68
|
+
results (Arctic Shift); an unscoped `query` searches all of Reddit via web
|
|
69
|
+
discovery — a site-restricted web search finds posts, then they are read from the
|
|
70
|
+
archive by ID (max 10 per call, ordered by relevance, and `after`/`before` do not
|
|
71
|
+
apply on that path). `after`/`before` accept ISO dates,
|
|
70
72
|
epoch seconds, or offsets like `"7d"`. Scores of posts under ~36h old read 0/1
|
|
71
73
|
until the archive backfills. Space calls out rather than firing them in
|
|
72
74
|
parallel — the archives shed load under concurrent bursts.
|
|
@@ -54,6 +54,37 @@ function mainContentText($, html, url) {
|
|
|
54
54
|
return [title, flattenBodyText(load(mainHtml))].filter(Boolean).join('\n');
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
/** Characters of page text the model is shown; about 6k tokens. */
|
|
58
|
+
const SHOWN_TEXT_BUDGET = 24000;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The text the model reads: main content first, then the whole page when both
|
|
62
|
+
* fit the budget.
|
|
63
|
+
*
|
|
64
|
+
* Main content alone is what keeps the Cloudflare blog's headline from being
|
|
65
|
+
* "Skip to content", but Readability also drops the page chrome, and that is
|
|
66
|
+
* where racket-lang.org announces "Racket version 9.3 is available". A model
|
|
67
|
+
* that never sees the answer fills a required field with the nearest heading,
|
|
68
|
+
* which the provenance guard cannot fault because it carries no digits, and
|
|
69
|
+
* the full-text retry below never fires because nothing was nulled. Showing
|
|
70
|
+
* the page text after the main content puts the banner in view while the
|
|
71
|
+
* article still leads. A page too long for both gets main content alone, up
|
|
72
|
+
* to the budget — the old 6000-character cap cut a 20k-character article to
|
|
73
|
+
* its first third.
|
|
74
|
+
*
|
|
75
|
+
* @param {import('cheerio').CheerioAPI} $
|
|
76
|
+
* @param {string} html
|
|
77
|
+
* @param {string} url
|
|
78
|
+
* @param {string} textContent - whole-page text from fetchAndParse
|
|
79
|
+
* @returns {string}
|
|
80
|
+
*/
|
|
81
|
+
function shownText($, html, url, textContent) {
|
|
82
|
+
const main = mainContentText($, html, url);
|
|
83
|
+
if (!main) return textContent;
|
|
84
|
+
if (main.length + textContent.length > SHOWN_TEXT_BUDGET) return main;
|
|
85
|
+
return `${main}\n\n${textContent}`;
|
|
86
|
+
}
|
|
87
|
+
|
|
57
88
|
/**
|
|
58
89
|
* Missing, null, blank string or empty array — what "the extraction did not
|
|
59
90
|
* fill this field in" looks like across every extraction method.
|
|
@@ -147,6 +178,9 @@ export class ExtractStructuredTool {
|
|
|
147
178
|
tool: 'extract_structured'
|
|
148
179
|
});
|
|
149
180
|
|
|
181
|
+
// What the model reads — see shownText().
|
|
182
|
+
const shown = shownText($, html, url, textContent);
|
|
183
|
+
|
|
150
184
|
// Step 3: Try LLM extraction first
|
|
151
185
|
let extractionResult = null;
|
|
152
186
|
let extractionMethod = 'llm';
|
|
@@ -160,9 +194,9 @@ export class ExtractStructuredTool {
|
|
|
160
194
|
// local Ollama was never used.
|
|
161
195
|
llmAvailable = await llm.ready();
|
|
162
196
|
if (llmAvailable) {
|
|
163
|
-
const result = await llm.extractStructured(
|
|
197
|
+
const result = await llm.extractStructured(shown, schema, {
|
|
164
198
|
prompt: prompt || '',
|
|
165
|
-
maxContentLength:
|
|
199
|
+
maxContentLength: SHOWN_TEXT_BUDGET
|
|
166
200
|
});
|
|
167
201
|
// extractStructured swallows LLM failures and returns its keyword
|
|
168
202
|
// fallback. Only accept the result as an LLM extraction when it
|
|
@@ -246,13 +280,12 @@ export class ExtractStructuredTool {
|
|
|
246
280
|
// So when a REQUIRED field is nulled, retry once on the full page text,
|
|
247
281
|
// which is exactly what the guard will accept. Only a strictly better
|
|
248
282
|
// result is kept, so a retry can never make the answer worse.
|
|
249
|
-
|
|
250
|
-
if (guarded.missingRequired.length > 0 && shownText !== textContent && textContent) {
|
|
283
|
+
if (guarded.missingRequired.length > 0 && shown !== textContent && textContent) {
|
|
251
284
|
try {
|
|
252
285
|
const llm = this._ensureLLMManager(llmConfig || {});
|
|
253
286
|
const retry = await llm.extractStructured(textContent, schema, {
|
|
254
287
|
prompt: prompt || '',
|
|
255
|
-
maxContentLength:
|
|
288
|
+
maxContentLength: SHOWN_TEXT_BUDGET
|
|
256
289
|
});
|
|
257
290
|
if (retry?.method === 'llm') {
|
|
258
291
|
const retryGuarded = applyGuard(retry);
|
|
@@ -11,6 +11,7 @@ import { z } from 'zod';
|
|
|
11
11
|
import { fetchAndParse } from './_fetchAndParse.js';
|
|
12
12
|
import { ollamaBaseUrl, ollamaHeaders, selectOllamaModel } from '../../utils/ollamaConfig.js';
|
|
13
13
|
import { verifyNumericProvenance } from '../../utils/provenance.js';
|
|
14
|
+
import { extractionFormat } from '../../utils/extractionFormat.js';
|
|
14
15
|
import { fenceUntrusted } from '../../utils/untrustedContent.js';
|
|
15
16
|
// D1.3: SamplingClient for MCP sampling fallback (lazy — only imported if needed)
|
|
16
17
|
let _SamplingClient = null;
|
|
@@ -164,16 +165,12 @@ function extractBalancedJson(str) {
|
|
|
164
165
|
* as an object schema.
|
|
165
166
|
*/
|
|
166
167
|
function buildInputSchema(schema) {
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
//
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
for (const [key, val] of Object.entries(schema || {})) {
|
|
174
|
-
properties[key] = (val && typeof val === 'object') ? val : { type: 'string' };
|
|
175
|
-
}
|
|
176
|
-
return { type: 'object', properties, additionalProperties: true };
|
|
168
|
+
// Every field nullable, no `required`: the decoder must be allowed to say
|
|
169
|
+
// "not stated" — a required string it cannot leave null becomes an
|
|
170
|
+
// invention (R14: `scrape` json on racket-lang.org answered "Racket 5.1.0"
|
|
171
|
+
// for a page that says 9.3). The caller's schema still drives validation.
|
|
172
|
+
const format = extractionFormat(schema);
|
|
173
|
+
return format === 'json' ? { type: 'object', properties: {}, additionalProperties: true } : format;
|
|
177
174
|
}
|
|
178
175
|
|
|
179
176
|
/**
|
|
@@ -189,7 +186,7 @@ function jsonSchemaToZod(schema) {
|
|
|
189
186
|
if (!isJsonSchema) {
|
|
190
187
|
const shape = {};
|
|
191
188
|
for (const [key, val] of Object.entries(schema)) {
|
|
192
|
-
shape[key] = jsonSchemaToZod(typeof val === 'string' ? { type: val } : val).optional();
|
|
189
|
+
shape[key] = jsonSchemaToZod(typeof val === 'string' ? { type: val } : val).nullable().optional();
|
|
193
190
|
}
|
|
194
191
|
return z.object(shape).passthrough();
|
|
195
192
|
}
|
|
@@ -206,7 +203,11 @@ function jsonSchemaToZod(schema) {
|
|
|
206
203
|
const required = Array.isArray(schema.required) ? schema.required : [];
|
|
207
204
|
for (const [key, val] of Object.entries(schema.properties || {})) {
|
|
208
205
|
const field = jsonSchemaToZod(val);
|
|
209
|
-
|
|
206
|
+
// The model is told to answer null for a field the content never
|
|
207
|
+
// states, so null is the honest answer for a field the schema does
|
|
208
|
+
// not require — not a type violation. A required field stays strict:
|
|
209
|
+
// null there is exactly what the caller needs to hear about.
|
|
210
|
+
shape[key] = required.includes(key) ? field : field.nullable().optional();
|
|
210
211
|
}
|
|
211
212
|
return z.object(shape).passthrough();
|
|
212
213
|
}
|
|
@@ -561,7 +562,8 @@ export class ExtractWithLlm {
|
|
|
561
562
|
}
|
|
562
563
|
|
|
563
564
|
const systemMessage =
|
|
564
|
-
'You extract structured data from web content per the user\'s instructions. Return JSON only.'
|
|
565
|
+
'You extract structured data from web content per the user\'s instructions. Return JSON only. ' +
|
|
566
|
+
'Use null for any field the content does not state — never guess, infer, or fill a value from memory.';
|
|
565
567
|
|
|
566
568
|
const { userMessage, truncated: inputTruncated, original_length } = buildUserMessage(prompt, text, schema);
|
|
567
569
|
|
|
@@ -281,19 +281,21 @@ export class UnifiedScrapeTool {
|
|
|
281
281
|
if (fmt && typeof fmt === 'object' && fmt.type === 'json') {
|
|
282
282
|
try {
|
|
283
283
|
const extractWithLlm = await this._getExtractWithLlm();
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
284
|
+
// Script and template bodies are never rendered, but $('body').text()
|
|
285
|
+
// includes them — on a Shopify storefront that was 179KB of
|
|
286
|
+
// JavaScript, more than the page's real text, and it carried the
|
|
287
|
+
// very "Sold out" strings the strip had just removed from the DOM.
|
|
288
|
+
const { load } = await import('cheerio');
|
|
289
|
+
const $visible = load(html);
|
|
290
|
+
$visible('script, style, noscript, template').remove();
|
|
291
|
+
const pageText = $visible('body').text().replace(/\s+/g, ' ').trim();
|
|
292
|
+
// Main content first, then the whole page: Readability drops the page
|
|
293
|
+
// chrome, and racket-lang.org states its version there — shown the
|
|
294
|
+
// main content alone, the model answered "Racket" for a version the
|
|
295
|
+
// page gives as 9.3 (R14). The article still leads.
|
|
296
|
+
const text = onlyMainContent
|
|
297
|
+
? `${htmlToMarkdown(getMainHtml())}\n\n${pageText}`
|
|
298
|
+
: pageText;
|
|
297
299
|
const result = await extractWithLlm.execute({
|
|
298
300
|
content: text,
|
|
299
301
|
prompt: fmt.prompt || 'Extract structured data from this page content.',
|
|
@@ -133,7 +133,7 @@ export class RedditOfficialApiAdapter {
|
|
|
133
133
|
// The official API cannot honor an arbitrary date range (only coarse `t`
|
|
134
134
|
// buckets). Reject so `auto` mode falls back to the archives, which can.
|
|
135
135
|
if (v.after || v.before) {
|
|
136
|
-
throw new Error('official Reddit API cannot filter by date range — unset after/before, or use source:"arctic_shift"
|
|
136
|
+
throw new Error('official Reddit API cannot filter by date range — unset after/before, or use source:"arctic_shift" (scoped searches only — date filters need an archive)');
|
|
137
137
|
}
|
|
138
138
|
const sr = stripNamePrefix(subreddit);
|
|
139
139
|
const au = stripNamePrefix(author);
|
|
@@ -166,12 +166,12 @@ export class RedditSearchTool {
|
|
|
166
166
|
throw new Error('source:"reddit_api" needs Reddit app credentials — set REDDIT_CLIENT_ID and REDDIT_CLIENT_SECRET (create a "script" app at https://www.reddit.com/prefs/apps)');
|
|
167
167
|
}
|
|
168
168
|
if (v.mode === 'comments') {
|
|
169
|
-
throw new Error('the official Reddit API has no comment full-text search — use comments mode with source:"arctic_shift"
|
|
169
|
+
throw new Error('the official Reddit API has no comment full-text search — use comments mode with source:"arctic_shift" (scoped) or "web_discovery" (unscoped), or read a whole thread with mode:"thread"');
|
|
170
170
|
}
|
|
171
171
|
order = ['reddit_api'];
|
|
172
172
|
} else if (v.source === 'arctic_shift') {
|
|
173
173
|
if (!arcticPossible) {
|
|
174
|
-
throw new Error('Arctic Shift cannot keyword-search across all of Reddit — add a subreddit or author scope, or
|
|
174
|
+
throw new Error('Arctic Shift cannot keyword-search across all of Reddit — add a subreddit or author scope, or drop source so auto routes the query to web_discovery');
|
|
175
175
|
}
|
|
176
176
|
order = ['arctic_shift'];
|
|
177
177
|
} else if (v.source === 'pullpush') {
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The constrained-decoding shape for an LLM extraction.
|
|
3
|
+
*
|
|
4
|
+
* A caller's JSON Schema says which fields are REQUIRED of the result. Handed
|
|
5
|
+
* to the model verbatim as the output format, that word means something else:
|
|
6
|
+
* the decoder cannot emit null for a required string, so a model shown content
|
|
7
|
+
* that never states the field is forced to invent one. R14 caught it twice on
|
|
8
|
+
* the same day — racket-lang.org's version came back as the page title,
|
|
9
|
+
* rust-lang.org's as the word "stable" — while the same model on the same
|
|
10
|
+
* pages answered correctly the moment the fact was in front of it.
|
|
11
|
+
*
|
|
12
|
+
* So the format the model decodes against makes every property nullable and
|
|
13
|
+
* drops `required`. The caller's schema still drives validation and the
|
|
14
|
+
* provenance retry: a required field the model answered null is what those
|
|
15
|
+
* exist to catch, and a null is honest where a guess is not.
|
|
16
|
+
*
|
|
17
|
+
* @param {object} schema - caller's JSON Schema (or a flat hint map)
|
|
18
|
+
* @returns {object|'json'} an object schema for the decoder, or 'json' when the
|
|
19
|
+
* input carries no properties to constrain
|
|
20
|
+
*/
|
|
21
|
+
export function extractionFormat(schema) {
|
|
22
|
+
if (!schema || typeof schema !== 'object') return 'json';
|
|
23
|
+
const source = schema.properties && typeof schema.properties === 'object'
|
|
24
|
+
? schema
|
|
25
|
+
: { type: 'object', properties: flatHintProperties(schema) };
|
|
26
|
+
if (!source.properties || Object.keys(source.properties).length === 0) return 'json';
|
|
27
|
+
|
|
28
|
+
const properties = {};
|
|
29
|
+
for (const [key, prop] of Object.entries(source.properties)) {
|
|
30
|
+
properties[key] = nullable(prop);
|
|
31
|
+
}
|
|
32
|
+
const { required: _required, ...rest } = source;
|
|
33
|
+
return { additionalProperties: true, ...rest, type: 'object', properties };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** A flat `{ field: 'string' }` hint map as JSON-Schema properties. */
|
|
37
|
+
function flatHintProperties(hints) {
|
|
38
|
+
const properties = {};
|
|
39
|
+
for (const [key, val] of Object.entries(hints)) {
|
|
40
|
+
properties[key] = (val && typeof val === 'object') ? val : { type: 'string' };
|
|
41
|
+
}
|
|
42
|
+
return properties;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The property with `null` admitted to its type, when it names a single type. */
|
|
46
|
+
function nullable(prop) {
|
|
47
|
+
if (!prop || typeof prop !== 'object' || Array.isArray(prop)) return prop;
|
|
48
|
+
if (typeof prop.type !== 'string' || prop.type === 'null' || prop.type === 'object') return prop;
|
|
49
|
+
return { ...prop, type: [prop.type, 'null'] };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export default extractionFormat;
|
package/src/utils/provenance.js
CHANGED
|
@@ -228,6 +228,28 @@ function literalReadings(value) {
|
|
|
228
228
|
return [...spellings];
|
|
229
229
|
}
|
|
230
230
|
|
|
231
|
+
/** A version with two or more dot-separated numeric segments, inside prose. */
|
|
232
|
+
const EMBEDDED_VERSION = /(?<![\w.])v?\d+(?:\.\d+){2,}(?![\w.])/gi;
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* The spellings of every version-shaped token inside a prose string, or null
|
|
236
|
+
* when the value is not prose or carries none. "Racket 5.1.0" on a page that
|
|
237
|
+
* says 9.3 is the case that found this: the whole value is prose, so
|
|
238
|
+
* `literalReadings` left it alone, and the fabricated version rode through.
|
|
239
|
+
*
|
|
240
|
+
* @param {*} value
|
|
241
|
+
* @returns {string[][]|null} one spelling list per token
|
|
242
|
+
*/
|
|
243
|
+
function embeddedVersionReadings(value) {
|
|
244
|
+
if (typeof value !== 'string' || !/\s/.test(value.trim())) return null;
|
|
245
|
+
const tokens = value.match(EMBEDDED_VERSION);
|
|
246
|
+
if (!tokens) return null;
|
|
247
|
+
return tokens.map((token) => {
|
|
248
|
+
const lower = token.toLowerCase();
|
|
249
|
+
return [...new Set([lower, lower.replace(VERSION_PREFIX, '')])];
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
|
|
231
253
|
/**
|
|
232
254
|
* Replace every numeric value that is not present in the source with null.
|
|
233
255
|
*
|
|
@@ -286,8 +308,22 @@ export function verifyNumericProvenance(data, source) {
|
|
|
286
308
|
// Not a number, but possibly a version/date/SKU-shaped literal, which is
|
|
287
309
|
// the other thing a model invents when the page does not say.
|
|
288
310
|
const literals = literalReadings(node);
|
|
289
|
-
if (literals
|
|
290
|
-
|
|
311
|
+
if (literals !== null) {
|
|
312
|
+
if (inSource(literals)) {
|
|
313
|
+
verified++;
|
|
314
|
+
return node;
|
|
315
|
+
}
|
|
316
|
+
unverified.push({ path: path || '(root)', value: node, reason: 'not_found_in_source' });
|
|
317
|
+
return null;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Prose that carries a version-shaped token ("Racket 5.1.0"): the words
|
|
321
|
+
// may be the model's own re-wording, but the token has exactly one
|
|
322
|
+
// spelling and is checked like a bare one. Decimals ("1.5 million") and
|
|
323
|
+
// bare integers stay prose — a model may legitimately re-word those.
|
|
324
|
+
const embedded = embeddedVersionReadings(node);
|
|
325
|
+
if (embedded === null) return node;
|
|
326
|
+
if (embedded.every((spellings) => inSource(spellings))) {
|
|
291
327
|
verified++;
|
|
292
328
|
return node;
|
|
293
329
|
}
|
package/src/utils/robotsGate.js
CHANGED
|
@@ -135,6 +135,29 @@ export async function robotsPreflight(url, options = {}) {
|
|
|
135
135
|
* warnings: string[], overridden: boolean }>}
|
|
136
136
|
* @throws {BlockedHostError|RobotsDisallowedError}
|
|
137
137
|
*/
|
|
138
|
+
/**
|
|
139
|
+
* The headers an HTTP page fetch carries: identity, the Web Bot Auth signature
|
|
140
|
+
* when one is configured, and an EMPTY Accept-Language.
|
|
141
|
+
*
|
|
142
|
+
* Node's fetch adds `Accept-Language: *` whenever the caller sets none. Amazon
|
|
143
|
+
* answers this identity with a captcha interstitial when ANY Accept-Language
|
|
144
|
+
* rides along — `*`, en-US, de-DE, a full browser list — and serves the page
|
|
145
|
+
* when the header is absent (R14, bisected header by header with curl, then
|
|
146
|
+
* reproduced with Node's fetch). fetch offers no way to leave the header out,
|
|
147
|
+
* but an empty value is sent as-is instead of `*`, and Amazon treats empty as
|
|
148
|
+
* absent. The identity stays honest; no language preference is claimed.
|
|
149
|
+
* @param {string} [userAgent]
|
|
150
|
+
* @param {Record<string,string>} [signature]
|
|
151
|
+
* @returns {Record<string,string>}
|
|
152
|
+
*/
|
|
153
|
+
export function outboundHeaders(userAgent, signature = {}) {
|
|
154
|
+
return {
|
|
155
|
+
...identityHeaders({ userAgent }),
|
|
156
|
+
'Accept-Language': '',
|
|
157
|
+
...signature
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
138
161
|
export async function preflightFetch(url, options = {}) {
|
|
139
162
|
const decision = await robotsPreflight(url, options);
|
|
140
163
|
if (!decision.allowed) {
|
|
@@ -153,7 +176,7 @@ export async function preflightFetch(url, options = {}) {
|
|
|
153
176
|
const signature = signRequestHeaders(url) || {};
|
|
154
177
|
|
|
155
178
|
return {
|
|
156
|
-
headers:
|
|
179
|
+
headers: outboundHeaders(decision.userAgent, signature),
|
|
157
180
|
userAgent: decision.userAgent,
|
|
158
181
|
warnings: decision.warnings,
|
|
159
182
|
overridden: decision.overridden
|