crawlforge-mcp-server 5.3.1 → 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.
@@ -0,0 +1,80 @@
1
+ /**
2
+ * jsonPath.js — the minimal subtree selector for extract_embedded_state.
3
+ *
4
+ * Deliberately not a JSONPath engine: dotted keys and array indexes only, no
5
+ * wildcards, filters, slices or recursive descent. That is enough to turn a
6
+ * multi-megabyte state blob into the branch a caller actually wants, and a
7
+ * caller who needs more can select a branch and filter it themselves.
8
+ *
9
+ * Syntax: `next_data.props.pageProps`, `json_scripts.0.data`, `next_f[1a]`,
10
+ * `a.b[0].c`. A key containing a literal "." cannot be addressed.
11
+ */
12
+
13
+ /**
14
+ * Split a path into its segments.
15
+ * @param {string} path
16
+ * @returns {string[]}
17
+ */
18
+ export function parseJsonPath(path) {
19
+ const segments = [];
20
+ for (const part of path.split('.')) {
21
+ // "a[0][1]" -> "a", "0", "1"
22
+ const [head, ...brackets] = part.split('[');
23
+ if (head !== '') segments.push(head);
24
+ for (const bracket of brackets) {
25
+ const key = bracket.endsWith(']') ? bracket.slice(0, -1) : bracket;
26
+ segments.push(key.replace(/^['"]|['"]$/g, ''));
27
+ }
28
+ }
29
+ return segments.filter((segment) => segment !== '');
30
+ }
31
+
32
+ /**
33
+ * Describe what a caller could have asked for at a dead end, so a typo comes
34
+ * back as a fixable message rather than an empty result.
35
+ * @param {unknown} value
36
+ * @returns {string}
37
+ */
38
+ function describeOptions(value) {
39
+ if (Array.isArray(value)) return `array of length ${value.length}`;
40
+ if (value !== null && typeof value === 'object') {
41
+ const keys = Object.keys(value);
42
+ const shown = keys.slice(0, 25).join(', ');
43
+ return `available keys: ${shown}${keys.length > 25 ? `, … (${keys.length} total)` : ''}`;
44
+ }
45
+ return `a ${value === null ? 'null' : typeof value} value, which has no keys`;
46
+ }
47
+
48
+ /**
49
+ * Resolve a path against a parsed object.
50
+ *
51
+ * @param {unknown} root
52
+ * @param {string} path
53
+ * @returns {unknown}
54
+ * @throws {Error} when the path does not resolve — including the keys that
55
+ * were available at the point it stopped
56
+ */
57
+ export function selectJsonPath(root, path) {
58
+ const segments = parseJsonPath(path);
59
+ if (segments.length === 0) {
60
+ throw new Error(`Path "${path}" is empty`);
61
+ }
62
+
63
+ let current = root;
64
+ const walked = [];
65
+
66
+ for (const segment of segments) {
67
+ const container = current !== null && typeof current === 'object';
68
+ const key = Array.isArray(current) ? Number(segment) : segment;
69
+ if (!container || !(key in current)) {
70
+ const at = walked.length === 0 ? 'the result' : `"${walked.join('.')}"`;
71
+ throw new Error(
72
+ `Path "${path}" not found: ${at} has no "${segment}" (${describeOptions(current)})`
73
+ );
74
+ }
75
+ current = current[key];
76
+ walked.push(segment);
77
+ }
78
+
79
+ return current;
80
+ }
@@ -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;