crawlforge-mcp-server 5.3.0 → 5.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -4
- package/package.json +4 -3
- package/server.js +43 -19
- package/src/cli/commands/template.js +15 -4
- package/src/core/AuthManager.js +1 -0
- package/src/core/ResearchOrchestrator.js +16 -22
- package/src/core/llm/LLMManager.js +26 -3
- package/src/core/llm/OllamaProvider.js +14 -5
- package/src/schemas/toolOutputSchemas.js +11 -0
- package/src/skills/agent-skills/crawlforge-deep-research/SKILL.md +5 -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 +72 -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/embeddedState.js +304 -0
- package/src/utils/jsonLd.js +122 -0
- package/src/utils/jsonPath.js +80 -0
- package/src/utils/ollamaConfig.js +36 -2
- package/src/utils/provenance.js +197 -0
|
@@ -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,72 @@
|
|
|
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
|
+
import { extractEmbeddedState } from '../../utils/embeddedState.js';
|
|
13
|
+
import { selectJsonPath } from '../../utils/jsonPath.js';
|
|
14
|
+
|
|
15
|
+
// Above this, an unscoped result is big enough to be a problem for the caller
|
|
16
|
+
// (context window, transport) rather than just large. Warn — never truncate:
|
|
17
|
+
// a half-serialized object is worse than a big one, and `path` already gives
|
|
18
|
+
// the caller an exact way to ask for less.
|
|
19
|
+
const LARGE_RESULT_BYTES = 256_000;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @param {{ url: string, path?: string, user_agent?: string, respect_robots?: boolean }} params
|
|
23
|
+
*/
|
|
24
|
+
export async function extractEmbeddedStateHandler({ url, path, user_agent, respect_robots }) {
|
|
25
|
+
try {
|
|
26
|
+
// The raw `html` is used, not `$`: fetchAndParse strips <script> from the
|
|
27
|
+
// parsed tree by default, and every source here lives in a script tag.
|
|
28
|
+
const { html, finalUrl, warnings: fetchWarnings } = await fetchAndParse(url, {
|
|
29
|
+
userAgent: user_agent,
|
|
30
|
+
respectRobots: respect_robots,
|
|
31
|
+
tool: 'extract_embedded_state'
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const state = extractEmbeddedState(html);
|
|
35
|
+
const warnings = [...fetchWarnings, ...state.warnings];
|
|
36
|
+
|
|
37
|
+
if (state.found.length === 0) {
|
|
38
|
+
warnings.push(
|
|
39
|
+
'No embedded state found. The page may render entirely on the client, or ship its data in a format this tool does not read.'
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const data = path ? selectJsonPath(state.data, path) : state.data;
|
|
44
|
+
const bytes = Buffer.byteLength(JSON.stringify(data) ?? '');
|
|
45
|
+
|
|
46
|
+
if (!path && bytes > LARGE_RESULT_BYTES) {
|
|
47
|
+
const largest = state.found.reduce((a, b) => (b.bytes > a.bytes ? b : a));
|
|
48
|
+
warnings.push(
|
|
49
|
+
`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]}".`
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
content: [{
|
|
55
|
+
type: 'text',
|
|
56
|
+
text: JSON.stringify({
|
|
57
|
+
url: finalUrl,
|
|
58
|
+
found: state.found,
|
|
59
|
+
path: path || null,
|
|
60
|
+
bytes,
|
|
61
|
+
data,
|
|
62
|
+
warnings
|
|
63
|
+
}, null, 2)
|
|
64
|
+
}]
|
|
65
|
+
};
|
|
66
|
+
} catch (error) {
|
|
67
|
+
return {
|
|
68
|
+
content: [{ type: 'text', text: `Failed to extract embedded state: ${error.message}` }],
|
|
69
|
+
isError: true
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -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
|
}
|