crawlforge-mcp-server 5.3.1 → 5.4.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/README.md +5 -4
- package/package.json +3 -3
- package/server.js +42 -18
- package/src/cli/commands/template.js +15 -4
- package/src/core/AuthManager.js +1 -0
- package/src/schemas/toolOutputSchemas.js +11 -0
- package/src/skills/agent-skills/crawlforge-getting-started/SKILL.md +3 -3
- package/src/skills/agent-skills/crawlforge-getting-started/references/credits.md +1 -0
- package/src/skills/agent-skills/crawlforge-structured-extraction/SKILL.md +22 -3
- package/src/tools/basic/extractMetadata.js +31 -38
- package/src/tools/extract/extractEmbeddedState.js +73 -0
- package/src/tools/extract/extractStructured.js +48 -2
- package/src/tools/extract/extractWithLlm.js +29 -2
- package/src/tools/templates/ScrapeTemplateTool.js +107 -18
- package/src/utils/jsonLd.js +122 -0
- package/src/utils/provenance.js +197 -0
package/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
</p>
|
|
4
4
|
|
|
5
5
|
<p align="center">
|
|
6
|
-
<b>
|
|
6
|
+
<b>29 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
|
+
- **29 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 | ✅ 29 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` | ✅ | ❌ |
|
|
@@ -180,6 +180,7 @@ CrawlForge requires a CrawlForge API key — **every tool is metered and consume
|
|
|
180
180
|
| `get_batch_results` | 1 | Retrieve paginated results for a `batch_scrape` job by `batchId` |
|
|
181
181
|
| `scrape` | 2 | **Unified single-fetch, multi-format extraction.** Pass a `formats` array (markdown/html/rawHtml/text/links/metadata/screenshot/json-schema) plus `onlyMainContent`; one fetch serves every requested format with per-format partial-success warnings |
|
|
182
182
|
| `scrape_structured` | 2 | Extract structured data with CSS selectors |
|
|
183
|
+
| `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 |
|
|
183
184
|
| `extract_content` | 2 | Enhanced content extraction |
|
|
184
185
|
| `map_site` | 2 | Discover and map website structure (optional `search=` ranks the discovered URLs) |
|
|
185
186
|
| `process_document` | 2 | Multi-format document processing |
|
|
@@ -216,7 +217,7 @@ For the full canonical capabilities reference (all tools, CLI commands, stealth
|
|
|
216
217
|
| **Business** ($399) | 250,000 / month | Large scale operations |
|
|
217
218
|
|
|
218
219
|
**All plans include:**
|
|
219
|
-
- Access to all
|
|
220
|
+
- Access to all 29 tools
|
|
220
221
|
- Credits never expire; paid-plan credits roll over month to month
|
|
221
222
|
- API access and webhook notifications
|
|
222
223
|
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "crawlforge-mcp-server",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.4.1",
|
|
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 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",
|
|
7
7
|
"bin": {
|
|
8
8
|
"crawlforge": "src/cli/index.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.
|
|
117
|
+
"crawlforge-extractors": "^1.4.0",
|
|
118
118
|
"diff": "^9.0.0",
|
|
119
119
|
"dotenv": "^17.2.1",
|
|
120
120
|
"franc": "^6.2.0",
|
package/server.js
CHANGED
|
@@ -52,6 +52,7 @@ import { extractTextHandler } from "./src/tools/basic/extractText.js";
|
|
|
52
52
|
import { extractLinksHandler } from "./src/tools/basic/extractLinks.js";
|
|
53
53
|
import { extractMetadataHandler } from "./src/tools/basic/extractMetadata.js";
|
|
54
54
|
import { scrapeStructuredHandler } from "./src/tools/basic/scrapeStructured.js";
|
|
55
|
+
import { extractEmbeddedStateHandler } from "./src/tools/extract/extractEmbeddedState.js";
|
|
55
56
|
// D1.1 Resources + D1.2 Prompts + D1.4 Elicitation
|
|
56
57
|
import { ResourceRegistry } from "./src/resources/ResourceRegistry.js";
|
|
57
58
|
import { PROMPTS, getPromptMessages } from "./src/prompts/PromptRegistry.js";
|
|
@@ -105,7 +106,7 @@ const taskStore = createTaskStore({ logger });
|
|
|
105
106
|
const server = new McpServer({
|
|
106
107
|
name: "crawlforge",
|
|
107
108
|
version: "5.3.1",
|
|
108
|
-
description: "Production-ready MCP server with
|
|
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.",
|
|
109
110
|
homepage: "https://www.crawlforge.dev",
|
|
110
111
|
icon: "https://www.crawlforge.dev/icon.png",
|
|
111
112
|
icons: [{ src: "https://www.crawlforge.dev/icon.png", mimeType: "image/png", sizes: ["any"] }],
|
|
@@ -141,7 +142,7 @@ server.registerPrompt("getting-started", {
|
|
|
141
142
|
role: "user",
|
|
142
143
|
content: {
|
|
143
144
|
type: "text",
|
|
144
|
-
text: "You have access to CrawlForge MCP with
|
|
145
|
+
text: "You have access to CrawlForge MCP with 29 web scraping tools. Key tools:\n\n" +
|
|
145
146
|
"- fetch_url: Fetch raw HTML/content from any URL\n" +
|
|
146
147
|
"- extract_text: Extract clean text from a webpage\n" +
|
|
147
148
|
"- extract_content: Smart content extraction with readability\n" +
|
|
@@ -322,6 +323,11 @@ const COMPLIANCE_PARAMS = {
|
|
|
322
323
|
user_agent: z.string().optional().describe("Override the outbound User-Agent. CrawlForge identifies itself honestly by default; use this only for targets you have your own agreement with.")
|
|
323
324
|
};
|
|
324
325
|
|
|
326
|
+
// 3.4: the two tools that let an LLM produce values share one provenance control.
|
|
327
|
+
const VERIFY_NUMBERS_PARAM = {
|
|
328
|
+
verify_numbers: z.boolean().optional().default(true).describe("Numeric provenance guard (default: true): every price or numeric value the LLM returns must appear literally in the page source, else it is returned as null with a reason in `provenance.unverified`. Set false to get the model's raw numbers back, including ones it derived (a count, a sum, a total) rather than read off the page.")
|
|
329
|
+
};
|
|
330
|
+
|
|
325
331
|
|
|
326
332
|
// Tool: fetch_url
|
|
327
333
|
registerToolIfEnabled("fetch_url", {
|
|
@@ -366,10 +372,22 @@ registerToolIfEnabled("extract_metadata", {
|
|
|
366
372
|
annotations: { title: "Extract Metadata", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
367
373
|
inputSchema: {
|
|
368
374
|
url: z.string().url().describe("The URL to extract metadata from"),
|
|
375
|
+
json_ld_types: z.array(z.string()).optional().describe("Filter the returned JSON-LD to nodes of these schema.org types, e.g. [\"Product\",\"Offer\"]. Subtypes match their parent: \"Event\" returns MusicEvent, \"Offer\" returns AggregateOffer, \"ItemList\" returns BreadcrumbList. Nodes are found at any depth, including inside @graph and nested inside a parent node. When set, json_ld carries only the matching nodes instead of the raw dump, and json_ld_type_counts reports how many matched per requested type. Documented types: ItemList, Product, Offer, Event, JobPosting, RealEstateListing — any other schema.org type is matched exactly."),
|
|
369
376
|
...COMPLIANCE_PARAMS
|
|
370
377
|
}
|
|
371
378
|
}, withAuth("extract_metadata", extractMetadataHandler));
|
|
372
379
|
|
|
380
|
+
// Tool: extract_embedded_state
|
|
381
|
+
registerToolIfEnabled("extract_embedded_state", {
|
|
382
|
+
description: "Use this when a page's data lives in its embedded JavaScript state rather than its rendered HTML — Next.js (__NEXT_DATA__ and React Server Component payloads), Nuxt, Apollo, Redux (__INITIAL_STATE__, __PRELOADED_STATE__), and <script type=\"application/json\"> blocks. One fetch, exact values, no LLM in the extraction path, so nothing can be fabricated. Payloads are routinely over a megabyte — pass `path` to return one subtree instead of the whole blob. Example: extract_embedded_state({url: \"https://www.ticketmaster.com/discover/concerts\", path: \"next_data.props.pageProps\"})",
|
|
383
|
+
annotations: { title: "Extract Embedded State", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
384
|
+
inputSchema: {
|
|
385
|
+
url: z.string().url().describe("The URL to read embedded state from"),
|
|
386
|
+
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."),
|
|
387
|
+
...COMPLIANCE_PARAMS
|
|
388
|
+
}
|
|
389
|
+
}, withAuth("extract_embedded_state", extractEmbeddedStateHandler));
|
|
390
|
+
|
|
373
391
|
// Tool: scrape_structured
|
|
374
392
|
registerToolIfEnabled("scrape_structured", {
|
|
375
393
|
description: "Use this when you know the exact CSS selectors for the data you want — e.g. scraping a pricing table or product list with consistent markup. More reliable than LLM extraction for well-structured pages. By default each selector is matched independently across the whole page, so the returned arrays are NOT row-aligned: data.price[0] need not belong to the same row as data.name[0]. Pass row_selector to get aligned records instead — one object per row, null for a field the row lacks. Example: scrape_structured({url: \"https://shop.com/products\", row_selector: \".product-card\", selectors: {price: \".price\", name: \".product-title\"}})",
|
|
@@ -579,12 +597,12 @@ registerToolIfEnabled("map_site", {
|
|
|
579
597
|
...COMPLIANCE_PARAMS
|
|
580
598
|
},
|
|
581
599
|
outputSchema: OUTPUT_SCHEMAS.map_site
|
|
582
|
-
}, withAuth("map_site", async (
|
|
600
|
+
}, withAuth("map_site", async (params) => {
|
|
583
601
|
try {
|
|
584
|
-
if (!url) {
|
|
602
|
+
if (!params.url) {
|
|
585
603
|
return { content: [{ type: "text", text: "URL parameter is required" }], isError: true };
|
|
586
604
|
}
|
|
587
|
-
const result = await mapSiteTool.execute(
|
|
605
|
+
const result = await mapSiteTool.execute(params);
|
|
588
606
|
return dualOutput(result);
|
|
589
607
|
} catch (error) {
|
|
590
608
|
return { content: [{ type: "text", text: `Site mapping failed: ${error.message}` }], isError: true };
|
|
@@ -600,12 +618,12 @@ registerToolIfEnabled("extract_content", {
|
|
|
600
618
|
options: z.object({}).passthrough().optional().describe("Additional extraction options"),
|
|
601
619
|
...COMPLIANCE_PARAMS
|
|
602
620
|
}
|
|
603
|
-
}, withAuth("extract_content", async (
|
|
621
|
+
}, withAuth("extract_content", async (params) => {
|
|
604
622
|
try {
|
|
605
|
-
if (!url) {
|
|
623
|
+
if (!params.url) {
|
|
606
624
|
return { content: [{ type: "text", text: "URL parameter is required" }], isError: true };
|
|
607
625
|
}
|
|
608
|
-
const result = await extractContentTool.execute(
|
|
626
|
+
const result = await extractContentTool.execute(params);
|
|
609
627
|
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
610
628
|
} catch (error) {
|
|
611
629
|
return { content: [{ type: "text", text: `Content extraction failed: ${error.message}` }], isError: true };
|
|
@@ -624,12 +642,12 @@ registerToolIfEnabled("process_document", {
|
|
|
624
642
|
options: z.object({}).passthrough().optional().describe("Additional processing options (maxPages, pageRange:{start,end}, extractText, extractMetadata, outputFormat, ...)"),
|
|
625
643
|
...COMPLIANCE_PARAMS
|
|
626
644
|
}
|
|
627
|
-
}, withAuth("process_document", async (
|
|
645
|
+
}, withAuth("process_document", async (params) => {
|
|
628
646
|
try {
|
|
629
|
-
if (!source) {
|
|
647
|
+
if (!params.source) {
|
|
630
648
|
return { content: [{ type: "text", text: "Source parameter is required" }], isError: true };
|
|
631
649
|
}
|
|
632
|
-
const result = await processDocumentTool.execute(
|
|
650
|
+
const result = await processDocumentTool.execute(params);
|
|
633
651
|
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
634
652
|
} catch (error) {
|
|
635
653
|
return { content: [{ type: "text", text: `Document processing failed: ${error.message}` }], isError: true };
|
|
@@ -694,12 +712,16 @@ registerToolIfEnabled("extract_structured", {
|
|
|
694
712
|
}).optional().describe("LLM provider configuration for AI-powered extraction"),
|
|
695
713
|
fallbackToSelectors: z.boolean().optional().default(true).describe("Fall back to CSS selector extraction if LLM is unavailable"),
|
|
696
714
|
selectorHints: z.record(z.string()).optional().describe("CSS selector hints to guide extraction"),
|
|
697
|
-
...COMPLIANCE_PARAMS
|
|
715
|
+
...COMPLIANCE_PARAMS,
|
|
716
|
+
...VERIFY_NUMBERS_PARAM
|
|
698
717
|
},
|
|
699
718
|
outputSchema: OUTPUT_SCHEMAS.extract_structured
|
|
700
|
-
}, withAuth("extract_structured", async (
|
|
719
|
+
}, withAuth("extract_structured", async (params) => {
|
|
701
720
|
try {
|
|
702
|
-
|
|
721
|
+
// Forward params whole. This wrapper used to destructure a fixed six, which
|
|
722
|
+
// silently dropped respect_robots and user_agent — both declared here and
|
|
723
|
+
// read by the tool, so the G5 override was accepted and ignored.
|
|
724
|
+
const result = await extractStructuredTool.execute(params);
|
|
703
725
|
return dualOutput(result);
|
|
704
726
|
} catch (error) {
|
|
705
727
|
return { content: [{ type: "text", text: `Structured extraction failed: ${error.message}` }], isError: true };
|
|
@@ -718,7 +740,8 @@ registerToolIfEnabled("extract_with_llm", {
|
|
|
718
740
|
provider: z.enum(["openai", "anthropic", "ollama", "auto"]).optional().default("auto").describe("LLM provider. Defaults to 'ollama' (local, no key, http://localhost:11434). Use 'openai' or 'anthropic' for cloud models (requires the matching API key)."),
|
|
719
741
|
model: z.string().optional().describe("Override the model. For ollama, pass a name returned by list_ollama_models (e.g. 'llama3.2', 'qwen2.5:7b'). Defaults: openai='gpt-4o-mini', anthropic='claude-haiku-4-5-20251001', ollama='llama3.2' or $OLLAMA_DEFAULT_MODEL."),
|
|
720
742
|
maxTokens: z.number().optional().default(4096).describe("Maximum output tokens"),
|
|
721
|
-
...COMPLIANCE_PARAMS
|
|
743
|
+
...COMPLIANCE_PARAMS,
|
|
744
|
+
...VERIFY_NUMBERS_PARAM
|
|
722
745
|
}
|
|
723
746
|
}, withAuth("extract_with_llm", async (params) => {
|
|
724
747
|
try {
|
|
@@ -1555,11 +1578,12 @@ registerToolIfEnabled("localization", {
|
|
|
1555
1578
|
|
|
1556
1579
|
// Tool: scrape_template (D3.3 — pre-built site templates)
|
|
1557
1580
|
registerToolIfEnabled("scrape_template", {
|
|
1558
|
-
description: "Use this when you want structured data from a well-known site without writing custom selectors.
|
|
1581
|
+
description: "Use this when you want structured data from a well-known site or platform API without writing custom selectors. Three modes: a template id with a url (scrape_template({template:\"github-repo\", url:\"https://github.com/user/repo\"})); template:\"auto\" with a url, which picks the template from the URL and names its choice in the response; or template:\"list\" to enumerate every template with the URLs it handles. Page templates return one record — e-commerce, social, developer and news sites (shopify-product, amazon-product, github-repo, youtube-video, tweet, reddit-thread, hacker-news-front-page, producthunt-launch, stackoverflow-question, npm-package, linkedin-profile). List connectors return N records from one call and are driven by params instead of a url: job boards (Greenhouse, Lever, Ashby, Workable, Recruitee, Teamtailor) return a company's whole careers board, US government APIs (NHTSA VIN decode, NPI provider registry) answer keyless lookups, and shopify-collection returns a whole collection. Example: scrape_template({template:\"greenhouse-jobs\", params:{company:\"stripe\"}})",
|
|
1559
1582
|
annotations: { title: "Scrape Template", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
1560
1583
|
inputSchema: {
|
|
1561
|
-
template: z.string().describe("Template ID (e.g. github-repo) or list to enumerate available templates"),
|
|
1562
|
-
url: z.string().url().optional().describe("URL to scrape — required unless template is list"),
|
|
1584
|
+
template: z.string().describe("Template ID (e.g. github-repo), \"auto\" to detect one from the url, or \"list\" to enumerate available templates"),
|
|
1585
|
+
url: z.string().url().optional().describe("URL to scrape — required unless template is list, or params drive a list connector"),
|
|
1586
|
+
params: z.record(z.any()).optional().describe("Parameters for a list connector, e.g. {company:\"stripe\"} for greenhouse-jobs or {store:\"www.allbirds.com\", collection:\"mens\"} for shopify-collection. Use template:\"list\" to see which templates take params"),
|
|
1563
1587
|
timeout: z.number().min(5000).max(60000).optional().default(15000).describe("Request timeout in milliseconds"),
|
|
1564
1588
|
...COMPLIANCE_PARAMS
|
|
1565
1589
|
}
|
|
@@ -8,8 +8,9 @@ import { runTool } from '../lib/runTool.js';
|
|
|
8
8
|
export function register(program) {
|
|
9
9
|
program
|
|
10
10
|
.command('template [id] [target]')
|
|
11
|
-
.description('Scrape using a pre-built site template (e.g. amazon-product, github-repo)')
|
|
11
|
+
.description('Scrape using a pre-built site template (e.g. amazon-product, github-repo, or auto to detect one from the URL)')
|
|
12
12
|
.option('--list', 'List all available templates')
|
|
13
|
+
.option('--params <json>', 'JSON parameters for a list connector, e.g. \'{"company":"stripe"}\'')
|
|
13
14
|
.action(async (id, target, opts, cmd) => {
|
|
14
15
|
const globals = cmd.parent.opts();
|
|
15
16
|
const cliFlags = { json: globals.json, pretty: globals.pretty, quiet: globals.quiet };
|
|
@@ -21,11 +22,21 @@ export function register(program) {
|
|
|
21
22
|
return;
|
|
22
23
|
}
|
|
23
24
|
|
|
24
|
-
|
|
25
|
-
|
|
25
|
+
let params;
|
|
26
|
+
if (opts.params) {
|
|
27
|
+
try {
|
|
28
|
+
params = JSON.parse(opts.params);
|
|
29
|
+
} catch (e) {
|
|
30
|
+
process.stderr.write(`Error parsing --params JSON: ${e.message}\n`);
|
|
31
|
+
process.exit(1);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (!id || (!target && !params)) {
|
|
36
|
+
process.stderr.write('Error: template requires <id> and <target>, or <id> with --params, or use --list\n');
|
|
26
37
|
process.exit(1);
|
|
27
38
|
}
|
|
28
39
|
|
|
29
|
-
await runTool(tool, { template: id, url: target }, cliFlags);
|
|
40
|
+
await runTool(tool, { template: id, url: target, params }, cliFlags);
|
|
30
41
|
});
|
|
31
42
|
}
|
package/src/core/AuthManager.js
CHANGED
|
@@ -259,6 +259,17 @@ const extractStructuredShape = {
|
|
|
259
259
|
errors: z.array(z.string()).optional()
|
|
260
260
|
}).passthrough().optional(),
|
|
261
261
|
extractionNotes: z.array(z.string()).optional(),
|
|
262
|
+
provenance: z.object({
|
|
263
|
+
enabled: z.boolean().optional().describe('Whether the numeric provenance guard ran'),
|
|
264
|
+
verified: z.number().optional().describe('Numeric values found literally in the page source'),
|
|
265
|
+
nulled: z.number().optional().describe('Numeric values replaced with null because the source does not contain them'),
|
|
266
|
+
unverified: z.array(z.object({
|
|
267
|
+
path: z.string().optional().describe('Path to the field, e.g. configurations[2].price'),
|
|
268
|
+
value: z.unknown().optional().describe('The value that was removed'),
|
|
269
|
+
reason: z.string().optional().describe('"not_found_in_source"')
|
|
270
|
+
}).passthrough()).optional(),
|
|
271
|
+
skipped: z.string().optional().describe('"empty_source" when there was nothing to check against')
|
|
272
|
+
}).passthrough().optional(),
|
|
262
273
|
_cost: costShape
|
|
263
274
|
};
|
|
264
275
|
|
|
@@ -8,7 +8,7 @@ metadata:
|
|
|
8
8
|
|
|
9
9
|
# CrawlForge: Getting Started
|
|
10
10
|
|
|
11
|
-
CrawlForge is an MCP server with **
|
|
11
|
+
CrawlForge is an MCP server with **29 tools** for web scraping, crawling,
|
|
12
12
|
extraction, research, change tracking, and AI-compliance. This skill orients you
|
|
13
13
|
and routes each request to the right specialized skill.
|
|
14
14
|
|
|
@@ -52,13 +52,13 @@ stored at `~/.crawlforge/config.json`.
|
|
|
52
52
|
| Watch a page for changes / monitor pricing | **crawlforge-change-tracking** |
|
|
53
53
|
| Scrape many URLs, run browser actions, generate llms.txt | **crawlforge-batch-automation** |
|
|
54
54
|
|
|
55
|
-
## The
|
|
55
|
+
## The 29 tools at a glance
|
|
56
56
|
|
|
57
57
|
- **Basic (5):** fetch_url, extract_text, extract_links, extract_metadata, scrape_structured
|
|
58
58
|
- **Unified (1):** scrape (multi-format single fetch)
|
|
59
59
|
- **Search & research (5):** search_web, serp_rank, reddit_search, deep_research, agent
|
|
60
60
|
- **Crawl (2):** crawl_deep, map_site
|
|
61
|
-
- **Extract & analyze (
|
|
61
|
+
- **Extract & analyze (8):** extract_content, process_document, summarize_content, analyze_content, extract_structured, extract_with_llm, extract_embedded_state, list_ollama_models
|
|
62
62
|
- **Batch & automation (4):** batch_scrape, get_batch_results, scrape_with_actions, generate_llms_txt
|
|
63
63
|
- **Stealth & locale (2):** stealth_mode, localization
|
|
64
64
|
- **Templates & tracking (2):** scrape_template, track_changes
|
|
@@ -21,6 +21,7 @@ metered; there is no free tier. Tools marked "scales" cost more as work grows.
|
|
|
21
21
|
|------|-------|
|
|
22
22
|
| `scrape` | Unified multi-format single fetch. |
|
|
23
23
|
| `scrape_structured` | CSS-selector extraction. |
|
|
24
|
+
| `extract_embedded_state` | Embedded JS state (`__NEXT_DATA__`, RSC, Nuxt, Apollo, Redux). |
|
|
24
25
|
| `extract_content` | Readability-cleaned article. |
|
|
25
26
|
| `map_site` | URL discovery / sitemap. |
|
|
26
27
|
| `process_document` | PDF / DOCX / TXT parsing. |
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: crawlforge-structured-extraction
|
|
3
|
-
description: "Extracts structured JSON and analyzes content with CrawlForge's extract_structured, extract_with_llm, scrape_structured, scrape_template, process_document, analyze_content, summarize_content, and list_ollama_models tools. Use when the user wants to extract specific fields, pull data into a JSON schema, extract by natural-language prompt, scrape with CSS selectors, get product, profile, or repo data from known sites (Amazon, LinkedIn, GitHub, YouTube, Reddit, and more), parse a PDF or DOCX, summarize a page, or analyze sentiment, entities, or keywords. Defaults to local Ollama for LLM extraction; OpenAI and Anthropic optional."
|
|
3
|
+
description: "Extracts structured JSON and analyzes content with CrawlForge's extract_structured, extract_with_llm, extract_embedded_state, scrape_structured, scrape_template, process_document, analyze_content, summarize_content, and list_ollama_models tools. Use when the user wants to extract specific fields, pull data into a JSON schema, extract by natural-language prompt, scrape with CSS selectors, read a page's embedded JavaScript state (__NEXT_DATA__, React Server Components, Nuxt, Apollo, Redux), get product, profile, or repo data from known sites (Amazon, LinkedIn, GitHub, YouTube, Reddit, and more), parse a PDF or DOCX, summarize a page, or analyze sentiment, entities, or keywords. Defaults to local Ollama for LLM extraction; OpenAI and Anthropic optional."
|
|
4
4
|
metadata:
|
|
5
5
|
version: 4.8.0
|
|
6
6
|
source: crawlforge-mcp-server
|
|
@@ -17,6 +17,7 @@ extraction method by how predictable the page is and whether an LLM is needed.
|
|
|
17
17
|
|-----------------|------|------|
|
|
18
18
|
| A well-known site (Amazon, GitHub, LinkedIn...) | `scrape_template` | 1 |
|
|
19
19
|
| Exact CSS selectors for the fields | `scrape_structured` | 2 |
|
|
20
|
+
| The data is in the page's JS state, not its HTML | `extract_embedded_state` | 2 |
|
|
20
21
|
| A JSON schema to fill (LLM, CSS fallback) | `extract_structured` | 3 |
|
|
21
22
|
| A natural-language extraction instruction | `extract_with_llm` | 3 |
|
|
22
23
|
| A PDF / DOCX / TXT to parse | `process_document` | 2 |
|
|
@@ -24,8 +25,26 @@ extraction method by how predictable the page is and whether an LLM is needed.
|
|
|
24
25
|
| Sentiment / entities / keywords / readability | `analyze_content` | 3 |
|
|
25
26
|
| To list local LLMs available for extraction | `list_ollama_models` | 1 |
|
|
26
27
|
|
|
27
|
-
Cheapest-first rule: try `scrape_template` → `scrape_structured`
|
|
28
|
-
before reaching for the LLM tools.
|
|
28
|
+
Cheapest-first rule: try `scrape_template` → `scrape_structured` /
|
|
29
|
+
`extract_embedded_state` (all deterministic) before reaching for the LLM tools.
|
|
30
|
+
On a React/Next/Nuxt page the values are usually sitting in the embedded state
|
|
31
|
+
already, exact and typed — that beats asking a model to read them off the render.
|
|
32
|
+
|
|
33
|
+
## extract_embedded_state — the page's own JS state (cost: 2)
|
|
34
|
+
|
|
35
|
+
```json
|
|
36
|
+
{ "tool": "extract_embedded_state", "params": { "url": "https://www.ticketmaster.com/discover/concerts", "path": "next_data.props.pageProps" } }
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Finds `__NEXT_DATA__`, `self.__next_f` (React Server Component payloads),
|
|
40
|
+
`window.__NUXT__`, `__APOLLO_STATE__`, `__INITIAL_STATE__`, `__PRELOADED_STATE__`
|
|
41
|
+
and `<script type="application/json">` blocks, keyed by source name. No LLM in
|
|
42
|
+
the path, so values are exact rather than inferred.
|
|
43
|
+
|
|
44
|
+
These payloads are routinely over a megabyte — pass `path` (dotted keys and
|
|
45
|
+
array indexes, e.g. `next_data.props.pageProps` or `next_f[0].f`) to return one
|
|
46
|
+
subtree. Without it, a large result comes back with a warning naming the biggest
|
|
47
|
+
source and a ready-to-paste path.
|
|
29
48
|
|
|
30
49
|
## scrape_template — known sites, zero selectors (cost: 1)
|
|
31
50
|
|
|
@@ -2,28 +2,12 @@
|
|
|
2
2
|
* extract_metadata — Extract page metadata (title, description, OG tags, etc.).
|
|
3
3
|
* Extracted from server.js inline handler.
|
|
4
4
|
* B1: Parse JSON-LD and microdata; stronger title fallback chain (og:title → <title> → h1).
|
|
5
|
+
* 3.3: json_ld_types promotes JSON-LD from a raw dump to a filtered extraction path.
|
|
5
6
|
*/
|
|
6
7
|
|
|
7
8
|
import { load } from 'cheerio';
|
|
8
9
|
import { fetchWithTimeout } from './_fetch.js';
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
* Parse all JSON-LD blocks from the document.
|
|
12
|
-
* @param {import('cheerio').CheerioAPI} $
|
|
13
|
-
* @returns {Array}
|
|
14
|
-
*/
|
|
15
|
-
function parseJsonLd($) {
|
|
16
|
-
const results = [];
|
|
17
|
-
$('script[type="application/ld+json"]').each((_, el) => {
|
|
18
|
-
try {
|
|
19
|
-
const raw = $(el).html();
|
|
20
|
-
if (raw) results.push(JSON.parse(raw));
|
|
21
|
-
} catch {
|
|
22
|
-
// Skip invalid blocks
|
|
23
|
-
}
|
|
24
|
-
});
|
|
25
|
-
return results;
|
|
26
|
-
}
|
|
10
|
+
import { parseJsonLd, filterJsonLdByType } from '../../utils/jsonLd.js';
|
|
27
11
|
|
|
28
12
|
/**
|
|
29
13
|
* Parse microdata items (elements with itemscope).
|
|
@@ -60,9 +44,10 @@ function parseMicrodata($) {
|
|
|
60
44
|
}
|
|
61
45
|
|
|
62
46
|
/**
|
|
63
|
-
* @param {{ url: string, user_agent?: string, respect_robots?: boolean
|
|
47
|
+
* @param {{ url: string, user_agent?: string, respect_robots?: boolean,
|
|
48
|
+
* json_ld_types?: string[] }} params
|
|
64
49
|
*/
|
|
65
|
-
export async function extractMetadataHandler({ url, user_agent, respect_robots }) {
|
|
50
|
+
export async function extractMetadataHandler({ url, user_agent, respect_robots, json_ld_types }) {
|
|
66
51
|
try {
|
|
67
52
|
const response = await fetchWithTimeout(url, {
|
|
68
53
|
userAgent: user_agent,
|
|
@@ -115,25 +100,33 @@ export async function extractMetadataHandler({ url, user_agent, respect_robots }
|
|
|
115
100
|
const jsonLd = parseJsonLd($);
|
|
116
101
|
const microdata = parseMicrodata($);
|
|
117
102
|
|
|
103
|
+
const result = {
|
|
104
|
+
title,
|
|
105
|
+
description,
|
|
106
|
+
keywords: keywords.split(',').map(k => k.trim()).filter(Boolean),
|
|
107
|
+
canonical_url: canonical,
|
|
108
|
+
author,
|
|
109
|
+
robots,
|
|
110
|
+
viewport,
|
|
111
|
+
charset,
|
|
112
|
+
og_tags: ogTags,
|
|
113
|
+
twitter_tags: twitterTags,
|
|
114
|
+
json_ld: jsonLd,
|
|
115
|
+
microdata,
|
|
116
|
+
url: response.url
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
// With a type filter, json_ld carries only the matching nodes — returning
|
|
120
|
+
// the raw dump as well would double the payload on the large pages that
|
|
121
|
+
// make filtering worth asking for.
|
|
122
|
+
if (json_ld_types?.length) {
|
|
123
|
+
const { items, counts } = filterJsonLdByType(jsonLd, json_ld_types);
|
|
124
|
+
result.json_ld = items;
|
|
125
|
+
result.json_ld_type_counts = counts;
|
|
126
|
+
}
|
|
127
|
+
|
|
118
128
|
return {
|
|
119
|
-
content: [{
|
|
120
|
-
type: 'text',
|
|
121
|
-
text: JSON.stringify({
|
|
122
|
-
title,
|
|
123
|
-
description,
|
|
124
|
-
keywords: keywords.split(',').map(k => k.trim()).filter(Boolean),
|
|
125
|
-
canonical_url: canonical,
|
|
126
|
-
author,
|
|
127
|
-
robots,
|
|
128
|
-
viewport,
|
|
129
|
-
charset,
|
|
130
|
-
og_tags: ogTags,
|
|
131
|
-
twitter_tags: twitterTags,
|
|
132
|
-
json_ld: jsonLd,
|
|
133
|
-
microdata,
|
|
134
|
-
url: response.url
|
|
135
|
-
}, null, 2)
|
|
136
|
-
}]
|
|
129
|
+
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }]
|
|
137
130
|
};
|
|
138
131
|
} catch (error) {
|
|
139
132
|
return {
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* extract_embedded_state — return the JSON state a page already ships in its
|
|
3
|
+
* own HTML: __NEXT_DATA__, RSC flight chunks (self.__next_f), __NUXT__,
|
|
4
|
+
* __APOLLO_STATE__, __INITIAL_STATE__, __PRELOADED_STATE__ and
|
|
5
|
+
* <script type="application/json"> blocks.
|
|
6
|
+
*
|
|
7
|
+
* One fetch, exact values, no LLM in the extraction path — the numbers come
|
|
8
|
+
* from the site's own serialized state, so they cannot be fabricated.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { fetchAndParse } from './_fetchAndParse.js';
|
|
12
|
+
// Both live in crawlforge-extractors so the REST API's extract_embedded_state
|
|
13
|
+
// runs this exact reader — one RSC flight-stream parser, not two.
|
|
14
|
+
import { extractEmbeddedState, selectJsonPath } from 'crawlforge-extractors';
|
|
15
|
+
|
|
16
|
+
// Above this, an unscoped result is big enough to be a problem for the caller
|
|
17
|
+
// (context window, transport) rather than just large. Warn — never truncate:
|
|
18
|
+
// a half-serialized object is worse than a big one, and `path` already gives
|
|
19
|
+
// the caller an exact way to ask for less.
|
|
20
|
+
const LARGE_RESULT_BYTES = 256_000;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @param {{ url: string, path?: string, user_agent?: string, respect_robots?: boolean }} params
|
|
24
|
+
*/
|
|
25
|
+
export async function extractEmbeddedStateHandler({ url, path, user_agent, respect_robots }) {
|
|
26
|
+
try {
|
|
27
|
+
// The raw `html` is used, not `$`: fetchAndParse strips <script> from the
|
|
28
|
+
// parsed tree by default, and every source here lives in a script tag.
|
|
29
|
+
const { html, finalUrl, warnings: fetchWarnings } = await fetchAndParse(url, {
|
|
30
|
+
userAgent: user_agent,
|
|
31
|
+
respectRobots: respect_robots,
|
|
32
|
+
tool: 'extract_embedded_state'
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const state = extractEmbeddedState(html);
|
|
36
|
+
const warnings = [...fetchWarnings, ...state.warnings];
|
|
37
|
+
|
|
38
|
+
if (state.found.length === 0) {
|
|
39
|
+
warnings.push(
|
|
40
|
+
'No embedded state found. The page may render entirely on the client, or ship its data in a format this tool does not read.'
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const data = path ? selectJsonPath(state.data, path) : state.data;
|
|
45
|
+
const bytes = Buffer.byteLength(JSON.stringify(data) ?? '');
|
|
46
|
+
|
|
47
|
+
if (!path && bytes > LARGE_RESULT_BYTES) {
|
|
48
|
+
const largest = state.found.reduce((a, b) => (b.bytes > a.bytes ? b : a));
|
|
49
|
+
warnings.push(
|
|
50
|
+
`Result is ${bytes} bytes; "${largest.name}" alone is ${largest.bytes}. Re-run with path to scope it, e.g. path:"${largest.name}.${Object.keys(state.data[largest.name])[0]}".`
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
content: [{
|
|
56
|
+
type: 'text',
|
|
57
|
+
text: JSON.stringify({
|
|
58
|
+
url: finalUrl,
|
|
59
|
+
found: state.found,
|
|
60
|
+
path: path || null,
|
|
61
|
+
bytes,
|
|
62
|
+
data,
|
|
63
|
+
warnings
|
|
64
|
+
}, null, 2)
|
|
65
|
+
}]
|
|
66
|
+
};
|
|
67
|
+
} catch (error) {
|
|
68
|
+
return {
|
|
69
|
+
content: [{ type: 'text', text: `Failed to extract embedded state: ${error.message}` }],
|
|
70
|
+
isError: true
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -11,6 +11,7 @@ import { LLMManager } from '../../core/llm/LLMManager.js';
|
|
|
11
11
|
import { CRAWLFORGE_USER_AGENT } from '../../utils/fetchIdentity.js';
|
|
12
12
|
import { fetchAndParse, flattenBodyText } from './_fetchAndParse.js';
|
|
13
13
|
import { extractMainContent } from '../scrape/_mainContent.js';
|
|
14
|
+
import { verifyNumericProvenance } from '../../utils/provenance.js';
|
|
14
15
|
|
|
15
16
|
// Semantic element selectors for well-known field names, tried as a last
|
|
16
17
|
// resort in the CSS fallback so common fields (e.g. "title") still resolve when
|
|
@@ -79,7 +80,8 @@ const ExtractStructuredSchema = z.object({
|
|
|
79
80
|
fallbackToSelectors: z.boolean().optional().default(true),
|
|
80
81
|
selectorHints: z.record(z.string()).optional(),
|
|
81
82
|
respect_robots: z.boolean().optional(),
|
|
82
|
-
user_agent: z.string().optional()
|
|
83
|
+
user_agent: z.string().optional(),
|
|
84
|
+
verify_numbers: z.boolean().optional().default(true)
|
|
83
85
|
});
|
|
84
86
|
|
|
85
87
|
export class ExtractStructuredTool {
|
|
@@ -136,7 +138,7 @@ export class ExtractStructuredTool {
|
|
|
136
138
|
|
|
137
139
|
try {
|
|
138
140
|
const validated = ExtractStructuredSchema.parse(params);
|
|
139
|
-
const { url, schema, prompt, llmConfig, fallbackToSelectors, selectorHints, respect_robots, user_agent } = validated;
|
|
141
|
+
const { url, schema, prompt, llmConfig, fallbackToSelectors, selectorHints, respect_robots, user_agent, verify_numbers } = validated;
|
|
140
142
|
|
|
141
143
|
// Step 1: Fetch and parse — shared helper strips scripts/styles/iframes/svgs
|
|
142
144
|
const { html, $, textContent, warnings } = await fetchAndParse(url, {
|
|
@@ -180,6 +182,43 @@ export class ExtractStructuredTool {
|
|
|
180
182
|
llmErrorMessage = llmError.message;
|
|
181
183
|
}
|
|
182
184
|
|
|
185
|
+
// Step 3b (3.4): numeric provenance. Only the LLM path invents numbers —
|
|
186
|
+
// the CSS and keyword fallbacks can only return text they read off the
|
|
187
|
+
// page — so the guard is scoped to it.
|
|
188
|
+
//
|
|
189
|
+
// It is checked against the FULL source, never `mainContentText()`: on
|
|
190
|
+
// the Apple MacBook Air page Readability keeps the FAQ block and every
|
|
191
|
+
// price is left behind in an embedded JSON blob, so checking against what
|
|
192
|
+
// the model was shown would null every correct price.
|
|
193
|
+
let provenance = { enabled: false };
|
|
194
|
+
if (extractionResult && extractionMethod === 'llm' && verify_numbers) {
|
|
195
|
+
const checked = verifyNumericProvenance(extractionResult.data || {}, `${html}\n${textContent}`);
|
|
196
|
+
// The model's own `valid` flag described the data before the guard ran.
|
|
197
|
+
// A required field the guard nulled is not filled in any more, so that
|
|
198
|
+
// flag cannot stand or the response reports a fabrication as valid.
|
|
199
|
+
const nulledRequired = checked.unverified
|
|
200
|
+
.map((entry) => entry.path)
|
|
201
|
+
.filter((path) => (schema.required || []).includes(path));
|
|
202
|
+
extractionResult = {
|
|
203
|
+
...extractionResult,
|
|
204
|
+
data: checked.data,
|
|
205
|
+
...(nulledRequired.length > 0 ? {
|
|
206
|
+
valid: false,
|
|
207
|
+
validationErrors: [
|
|
208
|
+
...(extractionResult.validationErrors || []),
|
|
209
|
+
...nulledRequired.map((field) => `Field "${field}" was not found in the page source`)
|
|
210
|
+
]
|
|
211
|
+
} : {})
|
|
212
|
+
};
|
|
213
|
+
provenance = {
|
|
214
|
+
enabled: true,
|
|
215
|
+
verified: checked.verified,
|
|
216
|
+
nulled: checked.nulled,
|
|
217
|
+
unverified: checked.unverified
|
|
218
|
+
};
|
|
219
|
+
if (checked.skipped) provenance.skipped = checked.skipped;
|
|
220
|
+
}
|
|
221
|
+
|
|
183
222
|
// Step 4: CSS selector fallback if LLM unavailable or failed
|
|
184
223
|
if (!extractionResult && fallbackToSelectors !== false) {
|
|
185
224
|
// D1.4: no LLM configured and the schema demands more than 3 required
|
|
@@ -223,6 +262,12 @@ export class ExtractStructuredTool {
|
|
|
223
262
|
if (llmErrorMessage) {
|
|
224
263
|
extractionNotes.push(`LLM extraction failed: ${llmErrorMessage}`);
|
|
225
264
|
}
|
|
265
|
+
if (provenance.nulled > 0) {
|
|
266
|
+
extractionNotes.push(
|
|
267
|
+
`Numeric provenance: ${provenance.nulled} value(s) the model returned are not in the page source and were replaced with null: ` +
|
|
268
|
+
provenance.unverified.map((u) => `${u.path}=${JSON.stringify(u.value)}`).join(', ')
|
|
269
|
+
);
|
|
270
|
+
}
|
|
226
271
|
|
|
227
272
|
// A required field that came back missing or empty is a failed
|
|
228
273
|
// extraction, not a successful one carrying a note: surface it at the
|
|
@@ -249,6 +294,7 @@ export class ExtractStructuredTool {
|
|
|
249
294
|
errors: extractionResult.validationErrors || []
|
|
250
295
|
},
|
|
251
296
|
extractionNotes,
|
|
297
|
+
provenance,
|
|
252
298
|
...(warnings?.length ? { warnings } : {})
|
|
253
299
|
};
|
|
254
300
|
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
import { z } from 'zod';
|
|
11
11
|
import { fetchAndParse } from './_fetchAndParse.js';
|
|
12
12
|
import { ollamaBaseUrl, ollamaHeaders, selectOllamaModel } from '../../utils/ollamaConfig.js';
|
|
13
|
+
import { verifyNumericProvenance } from '../../utils/provenance.js';
|
|
13
14
|
// D1.3: SamplingClient for MCP sampling fallback (lazy — only imported if needed)
|
|
14
15
|
let _SamplingClient = null;
|
|
15
16
|
async function getSamplingClient() {
|
|
@@ -485,6 +486,7 @@ export class ExtractWithLlm {
|
|
|
485
486
|
* @param {number} [params.maxTokens] - Max output tokens (default 4096)
|
|
486
487
|
* @param {boolean} [params.respect_robots] - Per-request robots.txt override
|
|
487
488
|
* @param {string} [params.user_agent] - Per-request identity override
|
|
489
|
+
* @param {boolean} [params.verify_numbers] - Numeric provenance guard (default true)
|
|
488
490
|
* @returns {Promise<Object>}
|
|
489
491
|
*/
|
|
490
492
|
async execute(params) {
|
|
@@ -497,7 +499,8 @@ export class ExtractWithLlm {
|
|
|
497
499
|
model: modelParam,
|
|
498
500
|
maxTokens = 4096,
|
|
499
501
|
respect_robots,
|
|
500
|
-
user_agent
|
|
502
|
+
user_agent,
|
|
503
|
+
verify_numbers = true
|
|
501
504
|
} = params;
|
|
502
505
|
|
|
503
506
|
// Validate: exactly one of url or content must be provided
|
|
@@ -530,18 +533,25 @@ export class ExtractWithLlm {
|
|
|
530
533
|
|
|
531
534
|
// Step 1: Get text to extract from
|
|
532
535
|
let text;
|
|
536
|
+
// What the provenance guard checks against. Deliberately wider than what
|
|
537
|
+
// the model is shown: the raw html carries numbers the flattened text does
|
|
538
|
+
// not (Apple's prices exist only inside an embedded JSON blob), and a value
|
|
539
|
+
// missing from `text` but present on the page must not be nulled.
|
|
540
|
+
let sourceForProvenance;
|
|
533
541
|
let fetchWarnings = [];
|
|
534
542
|
try {
|
|
535
543
|
if (url) {
|
|
536
|
-
const { textContent, warnings } = await fetchAndParse(url, {
|
|
544
|
+
const { html, textContent, warnings } = await fetchAndParse(url, {
|
|
537
545
|
respectRobots: respect_robots,
|
|
538
546
|
userAgent: user_agent,
|
|
539
547
|
tool: 'extract_with_llm'
|
|
540
548
|
});
|
|
541
549
|
text = textContent;
|
|
550
|
+
sourceForProvenance = `${html}\n${textContent}`;
|
|
542
551
|
fetchWarnings = warnings || [];
|
|
543
552
|
} else {
|
|
544
553
|
text = content;
|
|
554
|
+
sourceForProvenance = content;
|
|
545
555
|
}
|
|
546
556
|
} catch (fetchErr) {
|
|
547
557
|
return { success: false, error: `Failed to fetch content: ${fetchErr.message}` };
|
|
@@ -654,10 +664,27 @@ export class ExtractWithLlm {
|
|
|
654
664
|
}
|
|
655
665
|
}
|
|
656
666
|
|
|
667
|
+
// 3.4: numeric provenance. A number the model wrote that is nowhere in the
|
|
668
|
+
// page it was given was invented, so it comes back null with a reason
|
|
669
|
+
// rather than as a confident answer.
|
|
670
|
+
let provenance = { enabled: false };
|
|
671
|
+
if (verify_numbers) {
|
|
672
|
+
const checked = verifyNumericProvenance(parsed, sourceForProvenance);
|
|
673
|
+
parsed = checked.data;
|
|
674
|
+
provenance = {
|
|
675
|
+
enabled: true,
|
|
676
|
+
verified: checked.verified,
|
|
677
|
+
nulled: checked.nulled,
|
|
678
|
+
unverified: checked.unverified
|
|
679
|
+
};
|
|
680
|
+
if (checked.skipped) provenance.skipped = checked.skipped;
|
|
681
|
+
}
|
|
682
|
+
|
|
657
683
|
// C3: surface truncation metadata so callers know the input was clipped
|
|
658
684
|
const result = {
|
|
659
685
|
success: true,
|
|
660
686
|
data: parsed,
|
|
687
|
+
provenance,
|
|
661
688
|
provider: resolvedModel === 'sampling' ? 'sampling' : provider,
|
|
662
689
|
model: resolvedModel || model,
|
|
663
690
|
usage
|
|
@@ -4,44 +4,113 @@
|
|
|
4
4
|
* Usage pattern (D3.3):
|
|
5
5
|
* const tool = new ScrapeTemplateTool();
|
|
6
6
|
* const result = await tool.execute({ template: "github-repo", url: "https://github.com/user/repo" });
|
|
7
|
+
*
|
|
8
|
+
* Three ways in: a template id, `"auto"` (the registry picks the template from
|
|
9
|
+
* the URL and the response names the one it chose), and `"list"`. A list
|
|
10
|
+
* connector is driven by `params` rather than a URL and returns N entities from
|
|
11
|
+
* one call — the registry builds the URL, this tool fetches it.
|
|
7
12
|
*/
|
|
8
13
|
|
|
9
14
|
import { TemplateRegistry } from 'crawlforge-extractors';
|
|
10
15
|
import { safeFetch } from '../../utils/ssrfGuard.js';
|
|
11
16
|
import { preflightFetch } from '../../utils/robotsGate.js';
|
|
12
17
|
import { noteRetryAfter } from '../../utils/hostRateLimiter.js';
|
|
18
|
+
import { markPreflightRefusal } from '../../server/requestContext.js';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* A caller mistake caught before anything is fetched: no template matches the
|
|
22
|
+
* URL, a required list parameter is missing, a key-based connector has no key.
|
|
23
|
+
* We fetched nothing, so — like a robots refusal — it costs the caller nothing.
|
|
24
|
+
*/
|
|
25
|
+
function badRequest(message) {
|
|
26
|
+
markPreflightRefusal('BAD_REQUEST');
|
|
27
|
+
return new Error(message);
|
|
28
|
+
}
|
|
13
29
|
|
|
14
30
|
export class ScrapeTemplateTool {
|
|
15
|
-
|
|
16
|
-
|
|
31
|
+
/**
|
|
32
|
+
* @param {{ templates?: object[] }} [config] `templates` replaces the shipped
|
|
33
|
+
* set; tests use it to exercise a connector shape nothing ships yet.
|
|
34
|
+
*/
|
|
35
|
+
constructor(config = {}) {
|
|
36
|
+
this.registry = new TemplateRegistry(config?.templates);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The catalogue — no network. */
|
|
40
|
+
listTemplates() {
|
|
41
|
+
const templates = this.registry.list();
|
|
42
|
+
return { templates, count: templates.length };
|
|
17
43
|
}
|
|
18
44
|
|
|
19
45
|
/**
|
|
20
46
|
* Execute the scrape_template tool.
|
|
21
|
-
* @param {{ template: string, url
|
|
22
|
-
* user_agent?: string, respect_robots?: boolean }}
|
|
47
|
+
* @param {{ template: string, url?: string, params?: object, timeout?: number,
|
|
48
|
+
* user_agent?: string, respect_robots?: boolean }} request
|
|
23
49
|
* @returns {Promise<object>}
|
|
24
50
|
*/
|
|
25
|
-
async execute({ template, url, timeout = 15000, user_agent, respect_robots }) {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
51
|
+
async execute({ template, url, params, timeout = 15000, user_agent, respect_robots }) {
|
|
52
|
+
if (template === 'list') return this.listTemplates();
|
|
53
|
+
|
|
54
|
+
let templateId = template;
|
|
55
|
+
if (template === 'auto') {
|
|
56
|
+
if (!url) {
|
|
57
|
+
throw badRequest(
|
|
58
|
+
'template "auto" needs a url to detect from. Pass a url, or template:"list" to see every template.'
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
const detected = this.registry.detect(url);
|
|
62
|
+
if (!detected) {
|
|
63
|
+
throw badRequest(
|
|
64
|
+
`No template matches ${url}. Pass template:"list" to see every template and the URLs ` +
|
|
65
|
+
'each one handles, or name a template explicitly.'
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
templateId = detected.id;
|
|
69
|
+
} else if (!url && !params) {
|
|
70
|
+
// A template named with nothing to run it against still lists, as before.
|
|
71
|
+
return this.listTemplates();
|
|
32
72
|
}
|
|
33
73
|
|
|
34
74
|
// Validate template exists before making network call
|
|
35
|
-
const tpl = this.registry.get(
|
|
75
|
+
const tpl = this.registry.get(templateId);
|
|
36
76
|
if (!tpl) {
|
|
37
77
|
const available = this.registry.list().map(t => t.id).join(', ');
|
|
38
|
-
throw new Error(`Unknown template "${
|
|
78
|
+
throw new Error(`Unknown template "${templateId}". Available templates: ${available}`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// A key-based connector is answered here or not at all: the registry never
|
|
82
|
+
// reads process.env, and a missing key must not reach the target as a 401.
|
|
83
|
+
let apiKey;
|
|
84
|
+
if (tpl.requiresApiKey) {
|
|
85
|
+
apiKey = process.env[tpl.credentialRef];
|
|
86
|
+
if (!apiKey) {
|
|
87
|
+
throw badRequest(
|
|
88
|
+
`Template "${templateId}" reads an API-keyed endpoint. Set ${tpl.credentialRef} in the ` +
|
|
89
|
+
'server environment and try again.'
|
|
90
|
+
);
|
|
91
|
+
}
|
|
39
92
|
}
|
|
40
93
|
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
|
|
94
|
+
// The URL actually fetched comes from one of three places. The robots gate
|
|
95
|
+
// below runs against that URL, never the caller's input — listUrl in
|
|
96
|
+
// particular reaches a host the caller never named.
|
|
97
|
+
let fetchUrl;
|
|
98
|
+
if (params && tpl.listUrl) {
|
|
99
|
+
try {
|
|
100
|
+
fetchUrl = tpl.listUrl(apiKey ? { ...params, apiKey } : params);
|
|
101
|
+
} catch (error) {
|
|
102
|
+
// listUrl throws naming the parameter it wanted: a caller mistake, not
|
|
103
|
+
// a fetch that failed.
|
|
104
|
+
throw badRequest(error.message);
|
|
105
|
+
}
|
|
106
|
+
} else if (!url) {
|
|
107
|
+
throw badRequest(`Template "${templateId}" is reached by url, not params. Pass a url.`);
|
|
108
|
+
} else {
|
|
109
|
+
// A template may redirect its own fetch to a machine-readable endpoint
|
|
110
|
+
// (shopify-product reads /products/<handle>.json). Same host either way,
|
|
111
|
+
// so the SSRF guard below still applies.
|
|
112
|
+
fetchUrl = tpl.resolveUrl ? tpl.resolveUrl(url) : url;
|
|
113
|
+
}
|
|
45
114
|
|
|
46
115
|
// Robots gate + per-host politeness before any request to the target.
|
|
47
116
|
const gate = await preflightFetch(fetchUrl, {
|
|
@@ -76,8 +145,28 @@ export class ScrapeTemplateTool {
|
|
|
76
145
|
throw error;
|
|
77
146
|
}
|
|
78
147
|
|
|
148
|
+
// What the response reports. A params-driven call named no URL, so the one
|
|
149
|
+
// we built is the one to report — and the one the extractor quotes in its
|
|
150
|
+
// own error messages. Except on a key-based connector, where that URL
|
|
151
|
+
// carries the key: it goes into listUrl and nowhere else.
|
|
152
|
+
const keyed = Boolean(tpl.requiresApiKey);
|
|
153
|
+
const reportedUrl = url ?? (keyed ? undefined : fetchUrl);
|
|
154
|
+
const reportedFetchUrl = keyed ? reportedUrl : fetchUrl;
|
|
155
|
+
|
|
156
|
+
let echoParams;
|
|
157
|
+
if (params) {
|
|
158
|
+
echoParams = { ...params };
|
|
159
|
+
delete echoParams.apiKey;
|
|
160
|
+
}
|
|
161
|
+
|
|
79
162
|
// Run the template extractor
|
|
80
|
-
const result =
|
|
163
|
+
const result = tpl.extractList
|
|
164
|
+
? await this.registry.runList(templateId, html, { url: reportedUrl, params: echoParams })
|
|
165
|
+
: await this.registry.run(templateId, html, reportedUrl, reportedFetchUrl);
|
|
166
|
+
|
|
167
|
+
// run() stamps fetchedUrl itself; runList() does not.
|
|
168
|
+
if (tpl.extractList && reportedFetchUrl !== reportedUrl) result.fetchedUrl = reportedFetchUrl;
|
|
169
|
+
|
|
81
170
|
return gate.warnings.length > 0 ? { ...result, warnings: gate.warnings } : result;
|
|
82
171
|
}
|
|
83
172
|
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JSON-LD parsing and schema.org type filtering.
|
|
3
|
+
*
|
|
4
|
+
* Pure: no fetching. Callers pass a loaded cheerio document (parse) or already
|
|
5
|
+
* parsed blocks (filter).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* schema.org descendants that a filter on the parent type must also match.
|
|
10
|
+
*
|
|
11
|
+
* Real pages publish the specific subtype and almost never the parent, so an
|
|
12
|
+
* exact-string @type match returns nothing on exactly the pages callers ask
|
|
13
|
+
* about: ticketmaster.com/discover/concerts emits MusicEvent (never Event),
|
|
14
|
+
* apple.com/shop/buy-mac/macbook-air emits AggregateOffer and BreadcrumbList
|
|
15
|
+
* (never Offer or ItemList). Each list is the transitive descendant set of the
|
|
16
|
+
* key in schema.org v30. Types outside this table are matched exactly.
|
|
17
|
+
*/
|
|
18
|
+
export const JSON_LD_SUBTYPES = {
|
|
19
|
+
ItemList: ['BreadcrumbList', 'HowToSection', 'HowToStep', 'OfferCatalog'],
|
|
20
|
+
Product: [
|
|
21
|
+
'DietarySupplement', 'Drug', 'IndividualProduct', 'ProductCollection',
|
|
22
|
+
'ProductGroup', 'ProductModel', 'SomeProducts', 'Vehicle', 'BusOrCoach',
|
|
23
|
+
'Car', 'Motorcycle', 'MotorizedBicycle'
|
|
24
|
+
],
|
|
25
|
+
Offer: ['AggregateOffer', 'OfferForLease', 'OfferForPurchase'],
|
|
26
|
+
Event: [
|
|
27
|
+
'BusinessEvent', 'ChildrensEvent', 'ComedyEvent', 'CourseInstance',
|
|
28
|
+
'DanceEvent', 'DeliveryEvent', 'EducationEvent', 'EventSeries',
|
|
29
|
+
'ExhibitionEvent', 'Festival', 'FoodEvent', 'Hackathon', 'LiteraryEvent',
|
|
30
|
+
'MusicEvent', 'PublicationEvent', 'BroadcastEvent', 'OnDemandEvent',
|
|
31
|
+
'SaleEvent', 'ScreeningEvent', 'SocialEvent', 'SportsEvent', 'TheaterEvent',
|
|
32
|
+
'VisualArtsEvent'
|
|
33
|
+
],
|
|
34
|
+
JobPosting: [],
|
|
35
|
+
RealEstateListing: []
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
// Lowercased filter name → set of lowercased @type values it accepts.
|
|
39
|
+
const MATCH_SETS = new Map(
|
|
40
|
+
Object.entries(JSON_LD_SUBTYPES).map(([parent, subtypes]) => [
|
|
41
|
+
parent.toLowerCase(),
|
|
42
|
+
new Set([parent, ...subtypes].map((t) => t.toLowerCase()))
|
|
43
|
+
])
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Some publishers write @type as a full IRI ("https://schema.org/Product").
|
|
48
|
+
* @param {unknown} value
|
|
49
|
+
* @returns {string|null}
|
|
50
|
+
*/
|
|
51
|
+
function normalizeType(value) {
|
|
52
|
+
if (typeof value !== 'string') return null;
|
|
53
|
+
return value.replace(/^https?:\/\/schema\.org\//, '').trim().toLowerCase() || null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Parse all JSON-LD blocks from the document. A malformed block is skipped so
|
|
58
|
+
* one bad block does not lose the good ones.
|
|
59
|
+
* @param {import('cheerio').CheerioAPI} $
|
|
60
|
+
* @returns {Array}
|
|
61
|
+
*/
|
|
62
|
+
export function parseJsonLd($) {
|
|
63
|
+
const results = [];
|
|
64
|
+
$('script[type="application/ld+json"]').each((_, el) => {
|
|
65
|
+
try {
|
|
66
|
+
const raw = $(el).html();
|
|
67
|
+
if (raw) results.push(JSON.parse(raw));
|
|
68
|
+
} catch {
|
|
69
|
+
// Skip invalid blocks
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
return results;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Collect the JSON-LD nodes matching the requested schema.org types.
|
|
77
|
+
*
|
|
78
|
+
* Nodes are found at any depth, so @graph wrappers, top-level arrays and types
|
|
79
|
+
* nested inside another node (an Offer inside an Event) are all reachable.
|
|
80
|
+
* @type itself may be a string or an array of strings.
|
|
81
|
+
*
|
|
82
|
+
* @param {Array} blocks - parsed JSON-LD blocks, as returned by parseJsonLd
|
|
83
|
+
* @param {string[]} types - schema.org type names to keep
|
|
84
|
+
* @returns {{ items: Array, counts: Record<string, number> }} matching nodes in
|
|
85
|
+
* document order, and how many matched per requested type
|
|
86
|
+
*/
|
|
87
|
+
export function filterJsonLdByType(blocks, types) {
|
|
88
|
+
const wanted = types.map((requested) => ({
|
|
89
|
+
requested,
|
|
90
|
+
match: MATCH_SETS.get(requested.toLowerCase()) || new Set([requested.toLowerCase()])
|
|
91
|
+
}));
|
|
92
|
+
const counts = Object.fromEntries(types.map((t) => [t, 0]));
|
|
93
|
+
const items = [];
|
|
94
|
+
|
|
95
|
+
const visit = (node) => {
|
|
96
|
+
if (Array.isArray(node)) {
|
|
97
|
+
node.forEach(visit);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
if (!node || typeof node !== 'object') return;
|
|
101
|
+
|
|
102
|
+
const raw = node['@type'];
|
|
103
|
+
const nodeTypes = (Array.isArray(raw) ? raw : [raw]).map(normalizeType).filter(Boolean);
|
|
104
|
+
if (nodeTypes.length) {
|
|
105
|
+
let matched = false;
|
|
106
|
+
for (const { requested, match } of wanted) {
|
|
107
|
+
if (nodeTypes.some((t) => match.has(t))) {
|
|
108
|
+
counts[requested] += 1;
|
|
109
|
+
matched = true;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if (matched) items.push(node);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Descend even into a matched node: its children may match another
|
|
116
|
+
// requested type (Ticketmaster nests each Offer inside its MusicEvent).
|
|
117
|
+
for (const value of Object.values(node)) visit(value);
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
blocks.forEach(visit);
|
|
121
|
+
return { items, counts };
|
|
122
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* provenance -- numeric provenance guard for LLM-extracted data.
|
|
3
|
+
*
|
|
4
|
+
* A model handed page text that does not contain the number it was asked for
|
|
5
|
+
* does not say so: it writes a plausible one. On
|
|
6
|
+
* https://www.apple.com/shop/buy-mac/macbook-air every MacBook Air price lives
|
|
7
|
+
* only inside the page's embedded PRODUCT_SELECTION_BOOTSTRAP JSON — the
|
|
8
|
+
* rendered text carries no price at all — and extraction came back with 20
|
|
9
|
+
* confident, fabricated prices.
|
|
10
|
+
*
|
|
11
|
+
* The guard is the cheapest possible test of the only thing that matters: a
|
|
12
|
+
* number the model returned has to be *on the page*. Anything that is not is
|
|
13
|
+
* replaced with null and reported with a reason, so a caller can tell "the page
|
|
14
|
+
* does not say" apart from "the model said".
|
|
15
|
+
*
|
|
16
|
+
* Two rules make it safe to run by default:
|
|
17
|
+
*
|
|
18
|
+
* 1. It is checked against the FULL fetched source (raw html + flattened text),
|
|
19
|
+
* never the trimmed main content the model was fed. Readability keeps the
|
|
20
|
+
* FAQ block on that Apple page and drops every price; checking against what
|
|
21
|
+
* the model saw would null 100% of correct prices — a false null destroys a
|
|
22
|
+
* good extraction and is far more expensive than a false pass.
|
|
23
|
+
*
|
|
24
|
+
* 2. Matching is normalised on both sides, so 1299 is found in "$1,299.00",
|
|
25
|
+
* "1.299,00", "1 299", "1299.00" and in a value split across markup. Every
|
|
26
|
+
* ambiguous reading of a source number is admitted, because an extra reading
|
|
27
|
+
* can only make the guard more permissive, never null a real value.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/** Spaces (incl. NBSP / narrow NBSP) and the Swiss apostrophe group digits. */
|
|
31
|
+
const GROUPING_CHARS = /[\s\u00a0\u202f\u2009']/g;
|
|
32
|
+
|
|
33
|
+
/** A number as it appears in text: digits plus grouping/decimal punctuation. */
|
|
34
|
+
const GROUPED_TOKEN = /\d[\d.,\u00a0\u202f\u2009' ]*\d|\d/g;
|
|
35
|
+
|
|
36
|
+
/** Bare digit runs — recovers "1", "2", "3" from a "1, 2, 3" grouped token. */
|
|
37
|
+
const DIGIT_RUN = /\d+/g;
|
|
38
|
+
|
|
39
|
+
/** Currency symbols ($ £ € ¥ …) are stripped before a value is read. */
|
|
40
|
+
const CURRENCY_SYMBOLS = /\p{Sc}/gu;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* A string that is a single formatted number and nothing else. Anything with a
|
|
44
|
+
* word in it ("From $999", "13-inch") is text, not a numeric field, and is left
|
|
45
|
+
* alone — the guard deliberately under-reaches rather than risk a false null.
|
|
46
|
+
*/
|
|
47
|
+
const NUMERIC_STRING = /^[+-]?\d+(?:[.,]\d{3})*(?:[.,]\d+)?$/;
|
|
48
|
+
|
|
49
|
+
/** Chained markup between digits (`<span>1</span><span>299</span>`) is welded. */
|
|
50
|
+
const MARKUP_BETWEEN_DIGITS = /(\d)(?:\s*<[^>]{0,120}>\s*)+([\d.,])/g;
|
|
51
|
+
const MAX_WELD_PASSES = 3;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Every numeric reading of one token, canonicalised.
|
|
55
|
+
*
|
|
56
|
+
* "1,299.00" -> 1299 | "1.299,00" -> 1299 | "1 299" -> 1299 | "1.299" -> both
|
|
57
|
+
* 1299 (de-DE grouping) and 1.299 (en-US decimal), since the token alone cannot
|
|
58
|
+
* settle which the page meant.
|
|
59
|
+
*
|
|
60
|
+
* @param {string} token
|
|
61
|
+
* @returns {string[]} canonical numeric strings
|
|
62
|
+
*/
|
|
63
|
+
function readings(token) {
|
|
64
|
+
const t = token.replace(GROUPING_CHARS, '');
|
|
65
|
+
const hasDot = t.includes('.');
|
|
66
|
+
const hasComma = t.includes(',');
|
|
67
|
+
const raw = [];
|
|
68
|
+
|
|
69
|
+
if (hasDot && hasComma) {
|
|
70
|
+
// The rightmost of the two is the decimal separator; the other groups.
|
|
71
|
+
const decimal = t.lastIndexOf('.') > t.lastIndexOf(',') ? '.' : ',';
|
|
72
|
+
const grouping = decimal === '.' ? ',' : '.';
|
|
73
|
+
raw.push(t.split(grouping).join('').replace(decimal, '.'));
|
|
74
|
+
} else if (hasDot || hasComma) {
|
|
75
|
+
const sep = hasDot ? '.' : ',';
|
|
76
|
+
raw.push(t.split(sep).join('')); // grouping reading
|
|
77
|
+
if (t.split(sep).length === 2) raw.push(t.replace(sep, '.')); // decimal reading
|
|
78
|
+
} else {
|
|
79
|
+
raw.push(t);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const out = [];
|
|
83
|
+
for (const candidate of raw) {
|
|
84
|
+
const num = Number(candidate);
|
|
85
|
+
if (Number.isFinite(num)) out.push(String(num));
|
|
86
|
+
}
|
|
87
|
+
return out;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Every number present in the source, canonicalised into a lookup set.
|
|
92
|
+
*
|
|
93
|
+
* The source is scanned twice: as given, and with markup between digits welded
|
|
94
|
+
* shut, so a price the page splits across two spans is still a number.
|
|
95
|
+
*
|
|
96
|
+
* @param {string} source - raw html and/or page text
|
|
97
|
+
* @returns {Set<string>}
|
|
98
|
+
*/
|
|
99
|
+
function numbersInSource(source) {
|
|
100
|
+
const variants = [source];
|
|
101
|
+
if (source.includes('<')) {
|
|
102
|
+
let welded = source;
|
|
103
|
+
for (let pass = 0; pass < MAX_WELD_PASSES; pass++) {
|
|
104
|
+
const next = welded.replace(MARKUP_BETWEEN_DIGITS, '$1$2');
|
|
105
|
+
if (next === welded) break;
|
|
106
|
+
welded = next;
|
|
107
|
+
}
|
|
108
|
+
if (welded !== source) variants.push(welded);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const found = new Set();
|
|
112
|
+
for (const variant of variants) {
|
|
113
|
+
for (const [token] of variant.matchAll(GROUPED_TOKEN)) {
|
|
114
|
+
for (const reading of readings(token)) found.add(reading);
|
|
115
|
+
}
|
|
116
|
+
for (const [run] of variant.matchAll(DIGIT_RUN)) {
|
|
117
|
+
for (const reading of readings(run)) found.add(reading);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return found;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The numeric readings of an extracted value, or null when the value is not a
|
|
125
|
+
* numeric field at all.
|
|
126
|
+
*
|
|
127
|
+
* A numeric field is identified by the SHAPE of the value, not the name of the
|
|
128
|
+
* field: a JS number anywhere in the result, or a string that is entirely a
|
|
129
|
+
* formatted number once currency symbols and spaces are removed. Field names
|
|
130
|
+
* are no help in either direction — a price can arrive under `mainOffer`, and a
|
|
131
|
+
* field called `price` can legitimately hold "Contact us".
|
|
132
|
+
*
|
|
133
|
+
* @param {*} value
|
|
134
|
+
* @returns {string[]|null}
|
|
135
|
+
*/
|
|
136
|
+
function valueReadings(value) {
|
|
137
|
+
if (typeof value === 'number') {
|
|
138
|
+
return Number.isFinite(value) ? [String(value)] : null;
|
|
139
|
+
}
|
|
140
|
+
if (typeof value !== 'string') return null;
|
|
141
|
+
const stripped = value.replace(CURRENCY_SYMBOLS, '').replace(GROUPING_CHARS, '');
|
|
142
|
+
if (!NUMERIC_STRING.test(stripped)) return null;
|
|
143
|
+
return readings(stripped.replace(/^\+/, ''));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Replace every numeric value that is not present in the source with null.
|
|
148
|
+
*
|
|
149
|
+
* Derived numbers — a count, a sum, a total the caller asked the model to
|
|
150
|
+
* compute — are not on the page and will not verify. They are nulled like any
|
|
151
|
+
* other absent number, but the value that was removed is returned in
|
|
152
|
+
* `unverified`, so nothing disappears silently: a caller that genuinely wanted
|
|
153
|
+
* a computed number can read it there, or re-run with the guard off.
|
|
154
|
+
*
|
|
155
|
+
* @param {*} data - parsed LLM output (object, array or scalar)
|
|
156
|
+
* @param {string} source - FULL fetched source, not trimmed main content
|
|
157
|
+
* @returns {{ data: *, verified: number, nulled: number,
|
|
158
|
+
* unverified: Array<{path: string, value: *, reason: string}>,
|
|
159
|
+
* skipped?: string }}
|
|
160
|
+
*/
|
|
161
|
+
export function verifyNumericProvenance(data, source) {
|
|
162
|
+
if (typeof source !== 'string' || source.trim() === '') {
|
|
163
|
+
// No source to check against. Nulling everything here would be a guess, not
|
|
164
|
+
// a finding.
|
|
165
|
+
return { data, verified: 0, nulled: 0, unverified: [], skipped: 'empty_source' };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const found = numbersInSource(source);
|
|
169
|
+
const unverified = [];
|
|
170
|
+
let verified = 0;
|
|
171
|
+
|
|
172
|
+
const walk = (node, path) => {
|
|
173
|
+
if (Array.isArray(node)) {
|
|
174
|
+
return node.map((item, i) => walk(item, `${path}[${i}]`));
|
|
175
|
+
}
|
|
176
|
+
if (node && typeof node === 'object') {
|
|
177
|
+
const out = {};
|
|
178
|
+
for (const [key, value] of Object.entries(node)) {
|
|
179
|
+
out[key] = walk(value, path ? `${path}.${key}` : key);
|
|
180
|
+
}
|
|
181
|
+
return out;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const candidates = valueReadings(node);
|
|
185
|
+
if (candidates === null) return node;
|
|
186
|
+
if (candidates.some((c) => found.has(c))) {
|
|
187
|
+
verified++;
|
|
188
|
+
return node;
|
|
189
|
+
}
|
|
190
|
+
unverified.push({ path: path || '(root)', value: node, reason: 'not_found_in_source' });
|
|
191
|
+
return null;
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
return { data: walk(data, ''), verified, nulled: unverified.length, unverified };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export default verifyNumericProvenance;
|