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.
Files changed (41) hide show
  1. package/CLAUDE.md +9 -6
  2. package/README.md +28 -8
  3. package/package.json +6 -5
  4. package/server.js +56 -16
  5. package/src/core/ActionExecutor.js +246 -66
  6. package/src/core/AuthManager.js +1 -0
  7. package/src/core/ChangeTracker.js +215 -22
  8. package/src/core/ResearchOrchestrator.js +9 -3
  9. package/src/core/SamplingClient.js +4 -5
  10. package/src/core/StealthBrowserManager.js +64 -18
  11. package/src/core/cache/CacheManager.js +7 -2
  12. package/src/core/crawlers/BFSCrawler.js +14 -6
  13. package/src/core/llm/LLMManager.js +61 -11
  14. package/src/core/llm/OllamaProvider.js +139 -0
  15. package/src/core/processing/BrowserProcessor.js +28 -2
  16. package/src/schemas/toolOutputSchemas.js +53 -1
  17. package/src/server/requestContext.js +26 -0
  18. package/src/server/transports/streamableHttp.js +54 -11
  19. package/src/server/withAuth.js +24 -6
  20. package/src/skills/agent-skills/crawlforge-deep-research/SKILL.md +26 -3
  21. package/src/skills/agent-skills/crawlforge-getting-started/SKILL.md +5 -4
  22. package/src/skills/agent-skills/crawlforge-getting-started/references/credits.md +1 -0
  23. package/src/skills/agent-skills/crawlforge-structured-extraction/SKILL.md +6 -4
  24. package/src/skills/agent-skills/crawlforge-structured-extraction/references/templates.md +2 -1
  25. package/src/tools/advanced/ScrapeWithActionsTool.js +4 -1
  26. package/src/tools/basic/_fetch.js +8 -2
  27. package/src/tools/basic/fetchUrl.js +4 -1
  28. package/src/tools/crawl/crawlDeep.js +19 -5
  29. package/src/tools/extract/extractStructured.js +16 -4
  30. package/src/tools/extract/extractWithLlm.js +80 -10
  31. package/src/tools/extract/listOllamaModels.js +4 -6
  32. package/src/tools/scrape/_brandingExtractor.js +1 -1
  33. package/src/tools/scrape/unifiedScrape.js +71 -5
  34. package/src/tools/search/adapters/redditOfficialApi.js +196 -0
  35. package/src/tools/search/redditNormalize.js +95 -0
  36. package/src/tools/search/redditSearch.js +326 -0
  37. package/src/tools/templates/ScrapeTemplateTool.js +8 -3
  38. package/src/utils/hiddenContent.js +330 -0
  39. package/src/utils/htmlToMarkdown.js +12 -2
  40. package/src/utils/ollamaConfig.js +121 -0
  41. 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
- return getTurndown().turndown(html).trim();
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
+ }