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
|
@@ -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;
|