crawlforge-mcp-server 5.1.0 → 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 +1 -1
- package/README.md +8 -4
- package/package.json +5 -4
- package/server.js +22 -14
- package/src/core/ActionExecutor.js +246 -66
- 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 +3 -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 +67 -91
- 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
|
@@ -1,325 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* TemplateRegistry — pre-built scraping templates for popular sites (D3.3).
|
|
3
|
-
*
|
|
4
|
-
* Each template is a self-contained object with:
|
|
5
|
-
* id — unique slug used as the `template` parameter
|
|
6
|
-
* name — human-readable name
|
|
7
|
-
* description — when to use this template
|
|
8
|
-
* targetPattern — regex matching URLs this template handles
|
|
9
|
-
* selectors — CSS selectors mapping field names to DOM locations
|
|
10
|
-
* postProcess — optional function(raw: Object) → Object for cleanup
|
|
11
|
-
*
|
|
12
|
-
* Templates do NOT make network calls. The ScrapeTemplateTool fetches the
|
|
13
|
-
* page and passes the parsed HTML to the template's extract() method.
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
import { load } from 'cheerio';
|
|
17
|
-
|
|
18
|
-
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
19
|
-
|
|
20
|
-
function text($, sel) {
|
|
21
|
-
return $(sel).first().text().trim() || null;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
function attr($, sel, attribute) {
|
|
25
|
-
return $(sel).first().attr(attribute) || null;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
function list($, sel) {
|
|
29
|
-
return $(sel).map((_, el) => $(el).text().trim()).get().filter(Boolean);
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
function listAttr($, sel, attribute) {
|
|
33
|
-
return $(sel).map((_, el) => $(el).attr(attribute)).get().filter(Boolean);
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
// ── Template definitions ─────────────────────────────────────────────────────
|
|
37
|
-
|
|
38
|
-
const TEMPLATES = [
|
|
39
|
-
{
|
|
40
|
-
id: 'amazon-product',
|
|
41
|
-
name: 'Amazon Product',
|
|
42
|
-
description: 'Scrape an Amazon product page for title, price, rating, reviews, ASIN, and description.',
|
|
43
|
-
targetPattern: /amazon\.(com|co\.uk|de|fr|jp|ca|com\.au)/i,
|
|
44
|
-
extract($) {
|
|
45
|
-
return {
|
|
46
|
-
title: text($, '#productTitle'),
|
|
47
|
-
price: text($, '.a-price .a-offscreen') || text($, '#priceblock_ourprice') || text($, '#priceblock_dealprice'),
|
|
48
|
-
currency: attr($, 'meta[itemprop="priceCurrency"]', 'content'),
|
|
49
|
-
rating: text($, '#acrPopover .a-size-base'),
|
|
50
|
-
review_count: text($, '#acrCustomerReviewText'),
|
|
51
|
-
asin: text($, 'input#ASIN') || attr($, 'input[name="ASIN"]', 'value'),
|
|
52
|
-
brand: text($, '#bylineInfo'),
|
|
53
|
-
description: text($, '#productDescription p') || text($, '#feature-bullets'),
|
|
54
|
-
images: listAttr($, '#altImages img.a-thumbnail-image', 'src').slice(0, 8),
|
|
55
|
-
availability: text($, '#availability span'),
|
|
56
|
-
category_breadcrumb: list($, '#wayfinding-breadcrumbs_feature_div a')
|
|
57
|
-
};
|
|
58
|
-
}
|
|
59
|
-
},
|
|
60
|
-
|
|
61
|
-
{
|
|
62
|
-
id: 'linkedin-profile',
|
|
63
|
-
name: 'LinkedIn Profile',
|
|
64
|
-
description: 'Scrape a LinkedIn public profile for name, headline, location, and about section.',
|
|
65
|
-
targetPattern: /linkedin\.com\/in\//i,
|
|
66
|
-
extract($) {
|
|
67
|
-
return {
|
|
68
|
-
name: text($, 'h1') || text($, '.top-card-layout__title'),
|
|
69
|
-
headline: text($, '.top-card-layout__headline') || text($, 'h2'),
|
|
70
|
-
location: text($, '.top-card-layout__first-subline') || text($, '.profile-info-subheader'),
|
|
71
|
-
about: text($, '.core-section-container__content p') || text($, '.summary'),
|
|
72
|
-
connections: text($, '.top-card__connections'),
|
|
73
|
-
current_company: text($, '.top-card-layout__card-inner-full-width .top-card-link'),
|
|
74
|
-
note: 'LinkedIn requires authentication for full profiles. This template works on public profile pages only.'
|
|
75
|
-
};
|
|
76
|
-
}
|
|
77
|
-
},
|
|
78
|
-
|
|
79
|
-
{
|
|
80
|
-
id: 'github-repo',
|
|
81
|
-
name: 'GitHub Repository',
|
|
82
|
-
description: 'Scrape a GitHub repository page for stars, forks, description, language, topics, and README summary.',
|
|
83
|
-
targetPattern: /github\.com\/[^/]+\/[^/]+\/?$/i,
|
|
84
|
-
extract($) {
|
|
85
|
-
return {
|
|
86
|
-
name: text($, 'strong[itemprop="name"] a') || text($, '.repository-content h1'),
|
|
87
|
-
description: attr($, 'meta[property="og:description"]', 'content') || text($, 'p.f4.my-3'),
|
|
88
|
-
stars: text($, '#repo-stars-counter-star') || text($, '[aria-label*="stargazers"]'),
|
|
89
|
-
forks: text($, '#repo-network-counter') || text($, '[aria-label*="forks"]'),
|
|
90
|
-
// React (logged-out) layout has no watchers aria-label; the count is
|
|
91
|
-
// the <strong> right after the single octicon-eye. Language is a
|
|
92
|
-
// client-side skeleton on that layout — unrecoverable from static
|
|
93
|
-
// HTML, so it stays null there (itemprop still works on classic).
|
|
94
|
-
watchers: text($, '.octicon-eye + strong') || text($, '[aria-label*="watchers"]'),
|
|
95
|
-
language: text($, 'span[itemprop="programmingLanguage"]') || text($, '.d-inline-flex[class*="language"]'),
|
|
96
|
-
topics: list($, 'a.topic-tag, a[href^="/topics/"]'),
|
|
97
|
-
license: text($, 'a[href*="blob/"][href*="LICENSE"]') || text($, '.octicon-law ~ span'),
|
|
98
|
-
last_updated: attr($, 'relative-time', 'datetime'),
|
|
99
|
-
homepage: attr($, 'a[href][rel="noopener noreferrer"]', 'href'),
|
|
100
|
-
open_issues: text($, '.Counter[aria-label*="issue"]')
|
|
101
|
-
};
|
|
102
|
-
}
|
|
103
|
-
},
|
|
104
|
-
|
|
105
|
-
{
|
|
106
|
-
id: 'youtube-video',
|
|
107
|
-
name: 'YouTube Video',
|
|
108
|
-
description: 'Scrape a YouTube video page for title, channel, views, likes, publish date, and description.',
|
|
109
|
-
targetPattern: /youtube\.com\/watch/i,
|
|
110
|
-
extract($) {
|
|
111
|
-
return {
|
|
112
|
-
title: attr($, 'meta[name="title"]', 'content') || attr($, 'meta[property="og:title"]', 'content'),
|
|
113
|
-
channel: attr($, 'link[itemprop="name"]', 'content') || text($, '#channel-name'),
|
|
114
|
-
channel_url: attr($, 'span[itemprop="author"] link[itemprop="url"]', 'href'),
|
|
115
|
-
views: attr($, 'meta[itemprop="interactionCount"]', 'content'),
|
|
116
|
-
published: attr($, 'meta[itemprop="uploadDate"]', 'content') || attr($, 'meta[itemprop="datePublished"]', 'content'),
|
|
117
|
-
description: attr($, 'meta[property="og:description"]', 'content'),
|
|
118
|
-
thumbnail: attr($, 'meta[property="og:image"]', 'content'),
|
|
119
|
-
duration: attr($, 'meta[itemprop="duration"]', 'content'),
|
|
120
|
-
video_id: (() => {
|
|
121
|
-
try {
|
|
122
|
-
return new URL($('link[rel="canonical"]').attr('href') || 'https://youtube.com').searchParams.get('v');
|
|
123
|
-
} catch {
|
|
124
|
-
return null;
|
|
125
|
-
}
|
|
126
|
-
})()
|
|
127
|
-
};
|
|
128
|
-
}
|
|
129
|
-
},
|
|
130
|
-
|
|
131
|
-
{
|
|
132
|
-
id: 'tweet',
|
|
133
|
-
name: 'Tweet / X Post',
|
|
134
|
-
description: 'Scrape a tweet/X post for text, author, timestamp, likes, and retweets from the Open Graph / structured data.',
|
|
135
|
-
targetPattern: /(twitter|x)\.com\/[^/]+\/status\//i,
|
|
136
|
-
extract($) {
|
|
137
|
-
return {
|
|
138
|
-
text: attr($, 'meta[property="og:description"]', 'content'),
|
|
139
|
-
author: attr($, 'meta[property="og:title"]', 'content'),
|
|
140
|
-
url: attr($, 'meta[property="og:url"]', 'content') || attr($, 'link[rel="canonical"]', 'href'),
|
|
141
|
-
image: attr($, 'meta[property="og:image"]', 'content'),
|
|
142
|
-
note: 'X.com requires JavaScript rendering for full tweet data. Structured metadata is returned from static HTML.'
|
|
143
|
-
};
|
|
144
|
-
}
|
|
145
|
-
},
|
|
146
|
-
|
|
147
|
-
{
|
|
148
|
-
id: 'reddit-thread',
|
|
149
|
-
name: 'Reddit Thread',
|
|
150
|
-
description: 'Scrape a Reddit thread for title, subreddit, score, comment count, author, and top-level comments.',
|
|
151
|
-
targetPattern: /reddit\.com\/r\/[^/]+\/comments\//i,
|
|
152
|
-
extract($) {
|
|
153
|
-
return {
|
|
154
|
-
title: attr($, 'meta[property="og:title"]', 'content') || text($, 'h1'),
|
|
155
|
-
subreddit: text($, 'a[href*="/r/"][class*="subreddit"]') || (($('title').text().match(/r\/([^•]+)/) || [])[1] || '').trim(),
|
|
156
|
-
score: text($, '[data-score]') || attr($, '[itemprop="upvoteCount"]', 'content'),
|
|
157
|
-
author: text($, 'a[href*="/user/"]'),
|
|
158
|
-
posted: attr($, 'time[datetime]', 'datetime'),
|
|
159
|
-
body: text($, 'div[data-click-id="text"] p') || attr($, 'meta[property="og:description"]', 'content'),
|
|
160
|
-
url: attr($, 'meta[property="og:url"]', 'content'),
|
|
161
|
-
flair: text($, '[class*="flair"]')
|
|
162
|
-
};
|
|
163
|
-
}
|
|
164
|
-
},
|
|
165
|
-
|
|
166
|
-
{
|
|
167
|
-
id: 'hacker-news-front-page',
|
|
168
|
-
name: 'Hacker News Front Page',
|
|
169
|
-
description: 'Scrape the Hacker News front page for a list of stories with title, URL, score, and comment count.',
|
|
170
|
-
targetPattern: /news\.ycombinator\.com(\/news)?$/i,
|
|
171
|
-
extract($) {
|
|
172
|
-
const stories = [];
|
|
173
|
-
$('tr.athing').each((_, el) => {
|
|
174
|
-
const $row = $(el);
|
|
175
|
-
// The metadata row (".subtext") is the sibling row immediately after tr.athing.
|
|
176
|
-
const $subtext = $row.next('tr').find('.subtext');
|
|
177
|
-
const $score = $subtext.find('.score');
|
|
178
|
-
const $titleLink = $row.find('.titleline > a');
|
|
179
|
-
stories.push({
|
|
180
|
-
id: $row.attr('id'),
|
|
181
|
-
title: $titleLink.text().trim(),
|
|
182
|
-
url: $titleLink.attr('href'),
|
|
183
|
-
site: $row.find('.sitebit a').text().trim() || null,
|
|
184
|
-
score: $score.text().replace(' points', '').trim() || null,
|
|
185
|
-
author: $subtext.find('.hnuser').text().trim() || null,
|
|
186
|
-
// ".age a" wraps the relative age string ("3 hours ago"); its href is the item permalink.
|
|
187
|
-
posted: $subtext.find('.age a').text().trim() || null,
|
|
188
|
-
// The comments link is also an item?id= link, so exclude the age anchor.
|
|
189
|
-
// Job posts have no comments link at all -> null.
|
|
190
|
-
comments: $subtext.find('a[href*="item"]').not('.age a').last().text().trim() || null
|
|
191
|
-
});
|
|
192
|
-
});
|
|
193
|
-
return { stories: stories.slice(0, 30), scraped_at: new Date().toISOString() };
|
|
194
|
-
}
|
|
195
|
-
},
|
|
196
|
-
|
|
197
|
-
{
|
|
198
|
-
id: 'producthunt-launch',
|
|
199
|
-
name: 'Product Hunt Launch',
|
|
200
|
-
description: 'Scrape a Product Hunt product page for name, tagline, vote count, topics, and maker details.',
|
|
201
|
-
targetPattern: /producthunt\.com\/posts\//i,
|
|
202
|
-
extract($) {
|
|
203
|
-
return {
|
|
204
|
-
name: attr($, 'meta[property="og:title"]', 'content'),
|
|
205
|
-
tagline: attr($, 'meta[property="og:description"]', 'content'),
|
|
206
|
-
image: attr($, 'meta[property="og:image"]', 'content'),
|
|
207
|
-
url: attr($, 'meta[property="og:url"]', 'content'),
|
|
208
|
-
votes: text($, '[data-test="vote-button"] span') || text($, 'button[data-vote-button]'),
|
|
209
|
-
topics: list($, 'a[href*="/topics/"]'),
|
|
210
|
-
website: attr($, 'a[data-test="product-link"]', 'href') || attr($, 'a[href][rel="noopener"][target="_blank"]', 'href')
|
|
211
|
-
};
|
|
212
|
-
}
|
|
213
|
-
},
|
|
214
|
-
|
|
215
|
-
{
|
|
216
|
-
id: 'stackoverflow-question',
|
|
217
|
-
name: 'Stack Overflow Question',
|
|
218
|
-
description: 'Scrape a Stack Overflow question for title, body, votes, tags, answers, and accepted answer.',
|
|
219
|
-
targetPattern: /stackoverflow\.com\/questions\//i,
|
|
220
|
-
extract($) {
|
|
221
|
-
const answers = [];
|
|
222
|
-
$('.answer').each((_, el) => {
|
|
223
|
-
const $a = $(el);
|
|
224
|
-
answers.push({
|
|
225
|
-
votes: $a.find('[itemprop="upvoteCount"]').attr('content') || $a.find('.js-vote-count').text().trim(),
|
|
226
|
-
accepted: $a.hasClass('accepted-answer'),
|
|
227
|
-
body: $a.find('.s-prose').first().text().trim().slice(0, 500)
|
|
228
|
-
});
|
|
229
|
-
});
|
|
230
|
-
|
|
231
|
-
return {
|
|
232
|
-
title: text($, '#question-header h1'),
|
|
233
|
-
body: text($, '.question .s-prose'),
|
|
234
|
-
votes: text($, '.question .js-vote-count') || attr($, '.question [itemprop="upvoteCount"]', 'content'),
|
|
235
|
-
views: text($, '.js-view-count') || attr($, 'meta[name="twitter:data1"]', 'content'),
|
|
236
|
-
tags: list($, '.post-tag'),
|
|
237
|
-
author: text($, '.question .user-details a'),
|
|
238
|
-
asked: attr($, '.question time', 'datetime'),
|
|
239
|
-
answers: answers.slice(0, 5),
|
|
240
|
-
answered: $('div.accepted-answer').length > 0
|
|
241
|
-
};
|
|
242
|
-
}
|
|
243
|
-
},
|
|
244
|
-
|
|
245
|
-
{
|
|
246
|
-
id: 'npm-package',
|
|
247
|
-
name: 'npm Package',
|
|
248
|
-
description: 'Scrape an npm package page for name, version, description, weekly downloads, license, and dependencies.',
|
|
249
|
-
targetPattern: /npmjs\.com\/package\//i,
|
|
250
|
-
extract($) {
|
|
251
|
-
const scripts = [];
|
|
252
|
-
$('script[type="application/ld+json"]').each((_, el) => {
|
|
253
|
-
try { scripts.push(JSON.parse($(el).html())); } catch {}
|
|
254
|
-
});
|
|
255
|
-
const ld = scripts[0] || {};
|
|
256
|
-
|
|
257
|
-
return {
|
|
258
|
-
name: text($, 'h1') || ld.name,
|
|
259
|
-
version: text($, 'h3[data-testid="package-version-number"]') || text($, '[class*="version"]'),
|
|
260
|
-
description: attr($, 'meta[name="description"]', 'content') || text($, 'p[class*="description"]'),
|
|
261
|
-
license: text($, 'span[class*="license"]') || text($, '[data-cy="license"]') || ld.license,
|
|
262
|
-
weekly_downloads: text($, 'span[class*="weekly-downloads"]') || text($, '[data-cy="downloads"]'),
|
|
263
|
-
install_command: `npm install ${ld.name || text($, 'h1') || ''}`.trim(),
|
|
264
|
-
homepage: attr($, 'a[href][class*="homepage"]', 'href'),
|
|
265
|
-
repository: attr($, 'a[href*="github.com"]', 'href'),
|
|
266
|
-
maintainers: list($, 'a[href*="/~"]')
|
|
267
|
-
};
|
|
268
|
-
}
|
|
269
|
-
}
|
|
270
|
-
];
|
|
271
|
-
|
|
272
|
-
// ── Registry ─────────────────────────────────────────────────────────────────
|
|
273
|
-
|
|
274
|
-
export class TemplateRegistry {
|
|
275
|
-
constructor() {
|
|
276
|
-
this._templates = new Map(TEMPLATES.map(t => [t.id, t]));
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
/**
|
|
280
|
-
* List all registered template IDs and names.
|
|
281
|
-
* @returns {{ id: string, name: string, description: string }[]}
|
|
282
|
-
*/
|
|
283
|
-
list() {
|
|
284
|
-
return TEMPLATES.map(({ id, name, description, targetPattern }) => ({
|
|
285
|
-
id, name, description,
|
|
286
|
-
targetPattern: targetPattern.toString()
|
|
287
|
-
}));
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
/**
|
|
291
|
-
* Look up a template by ID.
|
|
292
|
-
* @param {string} id
|
|
293
|
-
* @returns {object|undefined}
|
|
294
|
-
*/
|
|
295
|
-
get(id) {
|
|
296
|
-
return this._templates.get(id);
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
/**
|
|
300
|
-
* Run a template against raw HTML.
|
|
301
|
-
* @param {string} id — template ID
|
|
302
|
-
* @param {string} html — raw HTML of the target page
|
|
303
|
-
* @param {string} url — original URL (for context)
|
|
304
|
-
* @returns {{ template: string, url: string, data: object, extractedAt: string }}
|
|
305
|
-
*/
|
|
306
|
-
async run(id, html, url) {
|
|
307
|
-
const template = this.get(id);
|
|
308
|
-
if (!template) {
|
|
309
|
-
throw new Error(`Unknown template: "${id}". Available: ${TEMPLATES.map(t => t.id).join(', ')}`);
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
const $ = load(html);
|
|
313
|
-
const data = template.extract($);
|
|
314
|
-
|
|
315
|
-
return {
|
|
316
|
-
template: id,
|
|
317
|
-
template_name: template.name,
|
|
318
|
-
url,
|
|
319
|
-
data,
|
|
320
|
-
extractedAt: new Date().toISOString()
|
|
321
|
-
};
|
|
322
|
-
}
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
export default TemplateRegistry;
|