crawlforge-mcp-server 5.0.5 → 5.2.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/CLAUDE.md +9 -6
- package/README.md +28 -8
- package/package.json +6 -5
- package/server.js +56 -16
- package/src/core/ActionExecutor.js +246 -66
- package/src/core/AuthManager.js +1 -0
- package/src/core/ChangeTracker.js +215 -22
- package/src/core/ResearchOrchestrator.js +9 -3
- package/src/core/SamplingClient.js +4 -5
- package/src/core/StealthBrowserManager.js +64 -18
- package/src/core/cache/CacheManager.js +7 -2
- package/src/core/crawlers/BFSCrawler.js +14 -6
- package/src/core/llm/LLMManager.js +61 -11
- package/src/core/llm/OllamaProvider.js +139 -0
- package/src/core/processing/BrowserProcessor.js +28 -2
- package/src/schemas/toolOutputSchemas.js +53 -1
- package/src/server/requestContext.js +26 -0
- package/src/server/transports/streamableHttp.js +54 -11
- package/src/server/withAuth.js +24 -6
- package/src/skills/agent-skills/crawlforge-deep-research/SKILL.md +26 -3
- package/src/skills/agent-skills/crawlforge-getting-started/SKILL.md +5 -4
- package/src/skills/agent-skills/crawlforge-getting-started/references/credits.md +1 -0
- package/src/skills/agent-skills/crawlforge-structured-extraction/SKILL.md +6 -4
- package/src/skills/agent-skills/crawlforge-structured-extraction/references/templates.md +2 -1
- package/src/tools/advanced/ScrapeWithActionsTool.js +4 -1
- package/src/tools/basic/_fetch.js +8 -2
- package/src/tools/basic/fetchUrl.js +4 -1
- package/src/tools/crawl/crawlDeep.js +19 -5
- package/src/tools/extract/extractStructured.js +16 -4
- package/src/tools/extract/extractWithLlm.js +80 -10
- package/src/tools/extract/listOllamaModels.js +4 -6
- package/src/tools/scrape/_brandingExtractor.js +1 -1
- package/src/tools/scrape/unifiedScrape.js +71 -5
- package/src/tools/search/adapters/redditOfficialApi.js +196 -0
- package/src/tools/search/redditNormalize.js +95 -0
- package/src/tools/search/redditSearch.js +326 -0
- package/src/tools/templates/ScrapeTemplateTool.js +8 -3
- package/src/utils/hiddenContent.js +330 -0
- package/src/utils/htmlToMarkdown.js +12 -2
- package/src/utils/ollamaConfig.js +121 -0
- package/src/tools/templates/TemplateRegistry.js +0 -325
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* hiddenContent -- remove content that a browser would not paint.
|
|
3
|
+
*
|
|
4
|
+
* Markup routinely carries text that is present in the DOM but invisible on
|
|
5
|
+
* screen: screen-reader-only labels, and theme badges that are hidden until a
|
|
6
|
+
* state class is applied. Serialising that markup to markdown or plain text
|
|
7
|
+
* drops the CSS, so the hidden text reads as live page content and downstream
|
|
8
|
+
* LLM extraction believes it.
|
|
9
|
+
*
|
|
10
|
+
* Observed on a Shopify Dawn storefront, whose price block ships every badge
|
|
11
|
+
* unconditionally and hides them in component CSS:
|
|
12
|
+
*
|
|
13
|
+
* <span class="visually-hidden">Regular price</span> -> clip-rect hidden
|
|
14
|
+
* <span class="price__badge-sold-out">Sold out</span> -> .price .price__badge-sold-out{display:none}
|
|
15
|
+
*
|
|
16
|
+
* Extraction read those and reported availability "Sold out" for a product with
|
|
17
|
+
* 100 units in stock, and a compare-at price the page never displayed.
|
|
18
|
+
*
|
|
19
|
+
* Only rules a browser applies unconditionally are honoured:
|
|
20
|
+
* - rules inside @media / @supports / @container are ignored, because they
|
|
21
|
+
* depend on viewport or capability (a Tailwind `hidden md:inline-block`
|
|
22
|
+
* element is visible on desktop and must survive)
|
|
23
|
+
* - a hide rule is skipped when the element also matches a rule that puts
|
|
24
|
+
* display back to a visible value (Dawn's `.price--sold-out
|
|
25
|
+
* .price__badge-sold-out{display:inline-block}`)
|
|
26
|
+
* - selectors carrying interaction pseudo-classes (:hover, :focus) or
|
|
27
|
+
* pseudo-elements are ignored, since they describe transient state
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { load } from 'cheerio';
|
|
31
|
+
|
|
32
|
+
/** Class tokens conventionally used to hide text from sighted users. */
|
|
33
|
+
const SCREEN_READER_CLASSES = [
|
|
34
|
+
'visually-hidden',
|
|
35
|
+
'visuallyhidden',
|
|
36
|
+
'sr-only',
|
|
37
|
+
'screen-reader-text',
|
|
38
|
+
'screen-reader-only',
|
|
39
|
+
'a11y-hidden',
|
|
40
|
+
'hidden-visually'
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Selectors we never act on even if a rule hides them, to stay conservative.
|
|
45
|
+
* '*' matters in particular: callers may flatten inline style attributes into
|
|
46
|
+
* synthetic `*{...}` rules, and honouring that would empty the document.
|
|
47
|
+
*/
|
|
48
|
+
const NEVER_REMOVE = new Set(['html', 'body', 'head', 'main', '*', ':root']);
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* An element holding more than this share of the page's text is treated as a
|
|
52
|
+
* JS-revealed wrapper rather than hidden furniture, and is left alone.
|
|
53
|
+
*/
|
|
54
|
+
const MAX_REMOVAL_FRACTION = 0.3;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Below this much content the share test is meaningless — in a short fragment a
|
|
58
|
+
* single badge can be a third of the content — so the wrapper guard is skipped.
|
|
59
|
+
*/
|
|
60
|
+
const MIN_TEXT_FOR_BULK_GUARD = 2000;
|
|
61
|
+
const MIN_MARKUP_FOR_BULK_GUARD = 20000;
|
|
62
|
+
|
|
63
|
+
/** Strip CSS comments and any at-rule block whose contents are conditional. */
|
|
64
|
+
function stripConditionalBlocks(css) {
|
|
65
|
+
let out = css.replace(/\/\*[\s\S]*?\*\//g, '');
|
|
66
|
+
|
|
67
|
+
// Remove @media/@supports/@container blocks wholesale, tracking nesting so a
|
|
68
|
+
// block containing other rules is removed in full.
|
|
69
|
+
const conditional = /@(?:media|supports|container)[^{]*\{/gi;
|
|
70
|
+
let match;
|
|
71
|
+
while ((match = conditional.exec(out)) !== null) {
|
|
72
|
+
const start = match.index;
|
|
73
|
+
let depth = 1;
|
|
74
|
+
let i = conditional.lastIndex;
|
|
75
|
+
while (i < out.length && depth > 0) {
|
|
76
|
+
if (out[i] === '{') depth++;
|
|
77
|
+
else if (out[i] === '}') depth--;
|
|
78
|
+
i++;
|
|
79
|
+
}
|
|
80
|
+
out = out.slice(0, start) + out.slice(i);
|
|
81
|
+
conditional.lastIndex = start;
|
|
82
|
+
}
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** True when cheerio cannot meaningfully evaluate the selector. */
|
|
87
|
+
function isUnsupportedSelector(selector) {
|
|
88
|
+
return (
|
|
89
|
+
// Progressive-enhancement rules. Themes ship <html class="no-js"> and swap
|
|
90
|
+
// the class to "js" on load, so static markup always looks like the no-JS
|
|
91
|
+
// case and these rules would hide content every real visitor sees.
|
|
92
|
+
/(^|[\s.#\[])no-js(\b|[.\[])/.test(selector) ||
|
|
93
|
+
selector.includes('::') ||
|
|
94
|
+
/:(hover|focus|focus-within|focus-visible|active|target|checked|disabled|placeholder|before|after|root|host|where|is|not\()/i.test(selector) ||
|
|
95
|
+
selector.includes('@') ||
|
|
96
|
+
selector.length === 0
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Approximate CSS specificity as a single comparable number.
|
|
102
|
+
* Ids weigh most, then classes/attributes/pseudo-classes, then element names.
|
|
103
|
+
* Enough to settle the cases that matter here, e.g. Dawn's
|
|
104
|
+
* `.price .price__badge-sold-out` (two classes) beating a bare `.badge`.
|
|
105
|
+
* @param {string} selector
|
|
106
|
+
* @returns {number}
|
|
107
|
+
*/
|
|
108
|
+
function specificity(selector) {
|
|
109
|
+
const ids = (selector.match(/#[\w-]+/g) || []).length;
|
|
110
|
+
const classes = (selector.match(/\.[\w-]+|\[[^\]]+\]|:[\w-]+/g) || []).length;
|
|
111
|
+
const elements = (selector.match(/(?:^|[\s>+~])[a-z][\w-]*/gi) || []).length;
|
|
112
|
+
return ids * 10000 + classes * 100 + elements;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Parse CSS into rules that hide content and rules that re-show it, each
|
|
117
|
+
* carrying the specificity and source order a browser would use to resolve
|
|
118
|
+
* the conflict.
|
|
119
|
+
* @param {string} css - Concatenated stylesheet text
|
|
120
|
+
* @returns {{hide: Array<{selector:string,spec:number,order:number,important:boolean}>, show: Array}}
|
|
121
|
+
*/
|
|
122
|
+
export function collectVisibilitySelectors(css) {
|
|
123
|
+
const hide = [];
|
|
124
|
+
const show = [];
|
|
125
|
+
if (!css) return { hide, show };
|
|
126
|
+
|
|
127
|
+
const flat = stripConditionalBlocks(css);
|
|
128
|
+
const rule = /([^{}]+)\{([^{}]*)\}/g;
|
|
129
|
+
let m;
|
|
130
|
+
let order = 0;
|
|
131
|
+
|
|
132
|
+
while ((m = rule.exec(flat)) !== null) {
|
|
133
|
+
const selectorList = m[1];
|
|
134
|
+
const body = m[2];
|
|
135
|
+
order++;
|
|
136
|
+
|
|
137
|
+
const display = /(?:^|[;{\s])display\s*:\s*([a-z-]+)/i.exec(body);
|
|
138
|
+
const visibility = /(?:^|[;{\s])visibility\s*:\s*(hidden|collapse)/i.exec(body);
|
|
139
|
+
// The standard visually-hidden recipe: collapsed to a 1px clipped box.
|
|
140
|
+
const clipped =
|
|
141
|
+
/clip\s*:\s*rect\(\s*0[\s,]/i.test(body) ||
|
|
142
|
+
/clip-path\s*:\s*inset\(\s*50%\s*\)/i.test(body);
|
|
143
|
+
|
|
144
|
+
const hides = (display && display[1].toLowerCase() === 'none') || visibility || clipped;
|
|
145
|
+
const shows = display && display[1].toLowerCase() !== 'none';
|
|
146
|
+
|
|
147
|
+
if (!hides && !shows) continue;
|
|
148
|
+
|
|
149
|
+
const important = /!\s*important/i.test(body);
|
|
150
|
+
|
|
151
|
+
for (const raw of selectorList.split(',')) {
|
|
152
|
+
const selector = raw.trim();
|
|
153
|
+
if (isUnsupportedSelector(selector) || NEVER_REMOVE.has(selector)) continue;
|
|
154
|
+
const entry = { selector, spec: specificity(selector), order, important };
|
|
155
|
+
if (hides) hide.push(entry);
|
|
156
|
+
else show.push(entry);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return { hide, show };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* True when rule `a` beats rule `b` in the cascade: !important first, then
|
|
165
|
+
* specificity, then source order.
|
|
166
|
+
*/
|
|
167
|
+
function wins(a, b) {
|
|
168
|
+
if (a.important !== b.important) return a.important;
|
|
169
|
+
if (a.spec !== b.spec) return a.spec > b.spec;
|
|
170
|
+
return a.order > b.order;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Elements whose contents a browser never renders as page text. */
|
|
174
|
+
const NON_RENDERED = 'script, style, noscript, template';
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Size of the content a browser would actually render inside an element.
|
|
178
|
+
*
|
|
179
|
+
* Script and style payloads must not count: on a commercial storefront they are
|
|
180
|
+
* an order of magnitude larger than the visible copy, and including them in the
|
|
181
|
+
* bulk-removal denominator made a wrapper holding the entire product section
|
|
182
|
+
* look like a minor fragment of the page.
|
|
183
|
+
*
|
|
184
|
+
* @param {import('cheerio').CheerioAPI} $
|
|
185
|
+
* @param {*} el - Element, or a cheerio selection
|
|
186
|
+
* @returns {{text: number, markup: number}} lengths in characters
|
|
187
|
+
*/
|
|
188
|
+
function renderedSize($, el) {
|
|
189
|
+
const $clone = $(el).clone();
|
|
190
|
+
$clone.find(NON_RENDERED).remove();
|
|
191
|
+
return {
|
|
192
|
+
text: $clone.text().replace(/\s+/g, ' ').trim().length,
|
|
193
|
+
markup: ($clone.html() || '').length
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Gather the text of every inline <style> block in the document. */
|
|
198
|
+
export function inlineStyleText($) {
|
|
199
|
+
const parts = [];
|
|
200
|
+
$('style').each((_, el) => {
|
|
201
|
+
const text = $(el).html();
|
|
202
|
+
if (text) parts.push(text);
|
|
203
|
+
});
|
|
204
|
+
return parts.join('\n');
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Remove browser-invisible content from a cheerio document, in place.
|
|
209
|
+
*
|
|
210
|
+
* @param {import('cheerio').CheerioAPI} $ - Parsed document
|
|
211
|
+
* @param {Object} [options]
|
|
212
|
+
* @param {string} [options.css] - Extra stylesheet text (e.g. linked sheets)
|
|
213
|
+
* @param {boolean} [options.useInlineStyles=true] - Honour inline <style> blocks
|
|
214
|
+
* @param {number} [options.maxRemovalFraction=0.3] - Skip elements holding more
|
|
215
|
+
* than this share of the page text (JS-revealed wrappers)
|
|
216
|
+
* @returns {{removed: number, skippedBulk: number}}
|
|
217
|
+
*/
|
|
218
|
+
export function stripHiddenFromDom($, options = {}) {
|
|
219
|
+
const { css = '', useInlineStyles = true, maxRemovalFraction = MAX_REMOVAL_FRACTION } = options;
|
|
220
|
+
let removed = 0;
|
|
221
|
+
let skippedBulk = 0;
|
|
222
|
+
|
|
223
|
+
// Genuinely invisible furniture is small: a badge, a label, a tooltip. An
|
|
224
|
+
// element holding a large share of the page is a wrapper that JavaScript
|
|
225
|
+
// reveals on load, and removing it would delete the page. Two real cases:
|
|
226
|
+
// Shopify's EasyLockdown app ships the whole storefront inside
|
|
227
|
+
// <div class="easylockdown-content" style="display:none">, and Next.js
|
|
228
|
+
// App Router streams the rendered page inside <div id="S:0" hidden> before
|
|
229
|
+
// moving it into place.
|
|
230
|
+
//
|
|
231
|
+
// Share is measured by markup as well as text, because the streamed Next.js
|
|
232
|
+
// wrapper is only ~8% of the page's text but half its markup — the visible
|
|
233
|
+
// copy is there while the remaining "text" is script payload.
|
|
234
|
+
//
|
|
235
|
+
// Both sides of the ratio count rendered content only. Measuring raw text
|
|
236
|
+
// put ~58KB of inline script into the denominator on a Shopify storefront,
|
|
237
|
+
// so the EasyLockdown wrapper — which held the whole product section,
|
|
238
|
+
// price included — scored 0.27 against a 0.3 threshold and was deleted.
|
|
239
|
+
const document = renderedSize($, 'body');
|
|
240
|
+
const documentTextLength = document.text || 1;
|
|
241
|
+
const documentMarkupLength = document.markup || 1;
|
|
242
|
+
const guardApplies =
|
|
243
|
+
documentTextLength >= MIN_TEXT_FOR_BULK_GUARD ||
|
|
244
|
+
documentMarkupLength >= MIN_MARKUP_FOR_BULK_GUARD;
|
|
245
|
+
|
|
246
|
+
const remove = (el) => {
|
|
247
|
+
const $el = $(el);
|
|
248
|
+
if (!$el.length || !$el.parent().length) return;
|
|
249
|
+
if (guardApplies) {
|
|
250
|
+
const size = renderedSize($, el);
|
|
251
|
+
const textShare = size.text / documentTextLength;
|
|
252
|
+
const markupShare = size.markup / documentMarkupLength;
|
|
253
|
+
if (Math.max(textShare, markupShare) > maxRemovalFraction) {
|
|
254
|
+
skippedBulk++;
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
$el.remove();
|
|
259
|
+
removed++;
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
// 1. The hidden attribute.
|
|
263
|
+
$('[hidden]').each((_, el) => remove(el));
|
|
264
|
+
|
|
265
|
+
// 2. Inline display:none / visibility:hidden.
|
|
266
|
+
$('[style]').each((_, el) => {
|
|
267
|
+
const style = ($(el).attr('style') || '').toLowerCase();
|
|
268
|
+
if (/display\s*:\s*none/.test(style) || /visibility\s*:\s*(hidden|collapse)/.test(style)) {
|
|
269
|
+
remove(el);
|
|
270
|
+
}
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
// 3. Conventional screen-reader-only classes.
|
|
274
|
+
for (const cls of SCREEN_READER_CLASSES) {
|
|
275
|
+
$(`.${cls}`).each((_, el) => remove(el));
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// 4. Rules from the page's own stylesheets.
|
|
279
|
+
const sheetText = [useInlineStyles ? inlineStyleText($) : '', css].filter(Boolean).join('\n');
|
|
280
|
+
if (sheetText) {
|
|
281
|
+
const { hide, show } = collectVisibilitySelectors(sheetText);
|
|
282
|
+
if (hide.length) {
|
|
283
|
+
// Query each distinct selector once, keeping the strongest hide rule.
|
|
284
|
+
const strongest = new Map();
|
|
285
|
+
for (const rule of hide) {
|
|
286
|
+
const prev = strongest.get(rule.selector);
|
|
287
|
+
if (!prev || wins(rule, prev)) strongest.set(rule.selector, rule);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
for (const rule of strongest.values()) {
|
|
291
|
+
let matches;
|
|
292
|
+
try {
|
|
293
|
+
matches = $(rule.selector);
|
|
294
|
+
} catch {
|
|
295
|
+
continue; // selector cheerio cannot parse
|
|
296
|
+
}
|
|
297
|
+
matches.each((_, el) => {
|
|
298
|
+
// Keep the element only when a re-showing rule actually wins the
|
|
299
|
+
// cascade. A bare `.badge{display:inline-block}` must not override
|
|
300
|
+
// `.price .price__badge-sold-out{display:none}`.
|
|
301
|
+
const reshown = show.some(s => {
|
|
302
|
+
if (!wins(s, rule)) return false;
|
|
303
|
+
try { return $(el).is(s.selector); } catch { return false; }
|
|
304
|
+
});
|
|
305
|
+
if (!reshown) remove(el);
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
return { removed, skippedBulk };
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Remove browser-invisible content from an HTML string.
|
|
316
|
+
*
|
|
317
|
+
* @param {string} html
|
|
318
|
+
* @param {Object} [options] - Same options as stripHiddenFromDom
|
|
319
|
+
* @returns {string} - HTML with hidden content removed
|
|
320
|
+
*/
|
|
321
|
+
export function stripHiddenHtml(html, options = {}) {
|
|
322
|
+
if (!html) return html;
|
|
323
|
+
try {
|
|
324
|
+
const $ = load(html);
|
|
325
|
+
stripHiddenFromDom($, options);
|
|
326
|
+
return $.html();
|
|
327
|
+
} catch {
|
|
328
|
+
return html; // never let cleanup break the caller
|
|
329
|
+
}
|
|
330
|
+
}
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
|
|
17
17
|
import TurndownService from 'turndown';
|
|
18
18
|
import { gfm } from 'turndown-plugin-gfm';
|
|
19
|
+
import { stripHiddenHtml } from './hiddenContent.js';
|
|
19
20
|
|
|
20
21
|
let _td = null;
|
|
21
22
|
|
|
@@ -45,12 +46,21 @@ function getTurndown() {
|
|
|
45
46
|
* Returns an empty string if html is falsy.
|
|
46
47
|
*
|
|
47
48
|
* @param {string} html
|
|
49
|
+
* @param {Object} [options]
|
|
50
|
+
* @param {string} [options.css] - Extra stylesheet text used to resolve visibility
|
|
51
|
+
* @param {boolean} [options.keepHiddenContent] - Skip the hidden-content strip
|
|
48
52
|
* @returns {string}
|
|
49
53
|
*/
|
|
50
|
-
export function htmlToMarkdown(html) {
|
|
54
|
+
export function htmlToMarkdown(html, options = {}) {
|
|
51
55
|
if (!html) return '';
|
|
52
56
|
try {
|
|
53
|
-
|
|
57
|
+
// Drop content a browser would not paint before converting. Turndown keeps
|
|
58
|
+
// the text of screen-reader-only labels and state-gated badges, which then
|
|
59
|
+
// reads as live page content once the CSS is gone.
|
|
60
|
+
const visible = options.keepHiddenContent
|
|
61
|
+
? html
|
|
62
|
+
: stripHiddenHtml(html, { css: options.css });
|
|
63
|
+
return getTurndown().turndown(visible).trim();
|
|
54
64
|
} catch {
|
|
55
65
|
// Fallback: strip tags, return plain text
|
|
56
66
|
return html.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared Ollama endpoint configuration.
|
|
3
|
+
*
|
|
4
|
+
* OLLAMA_BASE_URL — where the Ollama API lives (default http://localhost:11434).
|
|
5
|
+
* Set to https://ollama.com for Ollama Cloud, or to a tunnel
|
|
6
|
+
* URL fronting a self-hosted instance.
|
|
7
|
+
* OLLAMA_API_KEY — optional bearer token. Ollama Cloud requires it; a plain
|
|
8
|
+
* local instance ignores auth, so leaving it unset keeps the
|
|
9
|
+
* zero-config localhost behavior.
|
|
10
|
+
*
|
|
11
|
+
* Every HTTP call to Ollama (extract_with_llm, list_ollama_models, the
|
|
12
|
+
* SamplingClient fallback chain) must build its URL and headers from here so a
|
|
13
|
+
* hosted deployment configures the endpoint once.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export function ollamaBaseUrl() {
|
|
17
|
+
return (process.env.OLLAMA_BASE_URL || 'http://localhost:11434').replace(/\/$/, '');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @param {Record<string, string>} [extra] headers to merge (e.g. Content-Type)
|
|
22
|
+
* @returns {Record<string, string>}
|
|
23
|
+
*/
|
|
24
|
+
export function ollamaHeaders(extra = {}) {
|
|
25
|
+
const apiKey = process.env.OLLAMA_API_KEY;
|
|
26
|
+
return { ...extra, ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}) };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// ── Model selection ───────────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Local models ranked by extraction accuracy, measured 2026-08-25 against three
|
|
33
|
+
* live product pages (books.toscrape, an Amazon listing, a Shopify storefront)
|
|
34
|
+
* with independently verified ground truth. Score is correct fields out of 18
|
|
35
|
+
* over two runs; a page with no compare-at price counts a fabricated one as
|
|
36
|
+
* wrong.
|
|
37
|
+
*
|
|
38
|
+
* gemma3:4b 18/18 1040ms 45/45 over five runs
|
|
39
|
+
* gpt-oss:20b 18/18 3464ms
|
|
40
|
+
* gemma3:12b 16/18 3652ms invents a compare-at price
|
|
41
|
+
* mistral:7b 16/18 2377ms misses the rating
|
|
42
|
+
* llama3.2 16/18 1179ms invents a compare-at price, every run
|
|
43
|
+
* qwen2.5:3b 16/18 1067ms misses the title, every run
|
|
44
|
+
* dolphin-llama3:8b 12/18 6288ms
|
|
45
|
+
*
|
|
46
|
+
* Parameter count did not predict accuracy: the 4B model beat both the 12B and
|
|
47
|
+
* the 20B, and was three times faster than either.
|
|
48
|
+
*/
|
|
49
|
+
const PREFERRED_MODELS = [
|
|
50
|
+
'gemma3:4b',
|
|
51
|
+
'gpt-oss:20b',
|
|
52
|
+
'gemma3:12b',
|
|
53
|
+
'mistral:7b',
|
|
54
|
+
'llama3.2',
|
|
55
|
+
'qwen2.5:3b'
|
|
56
|
+
];
|
|
57
|
+
|
|
58
|
+
/** Used only when Ollama cannot be reached, so the error names a real model. */
|
|
59
|
+
export const FALLBACK_OLLAMA_MODEL = 'llama3.2';
|
|
60
|
+
|
|
61
|
+
/** Installed-model list per base URL. Keyed by URL so a changed endpoint re-probes. */
|
|
62
|
+
const _installedByBaseUrl = new Map();
|
|
63
|
+
|
|
64
|
+
/** "llama3.2" and "llama3.2:latest" name the same model. */
|
|
65
|
+
function baseName(name) {
|
|
66
|
+
return name.replace(/:latest$/, '');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Names of the models installed on the Ollama server, or [] if unreachable.
|
|
71
|
+
* Cached for the process lifetime — pulling a model mid-session is rare enough
|
|
72
|
+
* that re-listing on every extraction is not worth the round trip.
|
|
73
|
+
* @returns {Promise<string[]>}
|
|
74
|
+
*/
|
|
75
|
+
export async function installedOllamaModels() {
|
|
76
|
+
const url = ollamaBaseUrl();
|
|
77
|
+
if (!_installedByBaseUrl.has(url)) {
|
|
78
|
+
_installedByBaseUrl.set(url, (async () => {
|
|
79
|
+
try {
|
|
80
|
+
const response = await fetch(`${url}/api/tags`, {
|
|
81
|
+
headers: ollamaHeaders(),
|
|
82
|
+
signal: AbortSignal.timeout(3000)
|
|
83
|
+
});
|
|
84
|
+
if (!response.ok) return [];
|
|
85
|
+
const data = await response.json();
|
|
86
|
+
const models = Array.isArray(data?.models) ? data.models : [];
|
|
87
|
+
return models.map((m) => m.name).filter(Boolean);
|
|
88
|
+
} catch {
|
|
89
|
+
return [];
|
|
90
|
+
}
|
|
91
|
+
})());
|
|
92
|
+
}
|
|
93
|
+
return _installedByBaseUrl.get(url);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Choose which Ollama model to extract with.
|
|
98
|
+
*
|
|
99
|
+
* The code default used to be llama3.2 regardless of what was installed, and
|
|
100
|
+
* llama3.2 fabricates values — it invented a compare-at price on a Shopify
|
|
101
|
+
* product that has none, on every one of five runs. Hardcoding the winner
|
|
102
|
+
* instead would break anyone who has not pulled it, so the best *installed*
|
|
103
|
+
* model is chosen, and an explicit OLLAMA_DEFAULT_MODEL always wins.
|
|
104
|
+
*
|
|
105
|
+
* @returns {Promise<string>}
|
|
106
|
+
*/
|
|
107
|
+
export async function selectOllamaModel() {
|
|
108
|
+
const explicit = process.env.OLLAMA_DEFAULT_MODEL;
|
|
109
|
+
if (explicit) return explicit;
|
|
110
|
+
|
|
111
|
+
const installed = await installedOllamaModels();
|
|
112
|
+
if (installed.length === 0) return FALLBACK_OLLAMA_MODEL;
|
|
113
|
+
|
|
114
|
+
const byBase = new Map(installed.map((name) => [baseName(name), name]));
|
|
115
|
+
for (const preferred of PREFERRED_MODELS) {
|
|
116
|
+
const match = byBase.get(baseName(preferred));
|
|
117
|
+
if (match) return match;
|
|
118
|
+
}
|
|
119
|
+
// Nothing recognised — use whatever is there rather than failing.
|
|
120
|
+
return installed[0];
|
|
121
|
+
}
|