crawlforge-mcp-server 6.3.1 → 6.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "crawlforge-mcp-server",
3
- "version": "6.3.1",
3
+ "version": "6.4.0",
4
4
  "mcpName": "io.github.mysleekdesigns/crawlforge-mcp-server",
5
5
  "description": "CrawlForge MCP Server - Professional Model Context Protocol server with 30 web scraping, crawling, deep-research, and autonomous-extraction tools. Returns clean Markdown and structured JSON for Claude, Cursor, and any MCP client. Defaults to local Ollama for LLM extraction (no API key needed); OpenAI/Anthropic available as opt-in. Includes a unified multi-format scrape tool, an autonomous agent, pre-built site templates, and Camoufox stealth browsing.",
6
6
  "main": "server.js",
package/server.js CHANGED
@@ -107,7 +107,7 @@ if (configErrors.length > 0 && config.server.nodeEnv === 'production') {
107
107
  // Create the server
108
108
  const server = new McpServer({
109
109
  name: "crawlforge",
110
- version: "6.3.1",
110
+ version: "6.4.0",
111
111
  description: "Production-ready MCP server with 30 web scraping, crawling, and content processing tools. Features MCP Resources (crawlforge://), Prompts, Sampling fallback, Elicitation, stealth browsing, deep research, structured extraction, embedded JavaScript state extraction, real Google SERP rank tracking, Reddit search via community archives, change tracking, local-LLM extraction via Ollama, unified multi-format scrape, and autonomous agent tool.",
112
112
  homepage: "https://www.crawlforge.dev",
113
113
  icon: "https://www.crawlforge.dev/icon.png",
@@ -876,7 +876,8 @@ registerToolIfEnabled("get_batch_results", {
876
876
  inputSchema: {
877
877
  batchId: z.string().describe("The batch ID returned by batch_scrape"),
878
878
  page: z.number().min(1).default(1).describe("Page number (1-based)"),
879
- pageSize: z.number().min(1).max(100).default(25).describe("Number of results per page")
879
+ pageSize: z.number().min(1).max(100).default(25).describe("Number of results per page"),
880
+ ...MAX_INLINE_CHARS_PARAM
880
881
  }
881
882
  }, withAuth("get_batch_results", async ({ batchId, page = 1, pageSize = 25 }) => {
882
883
  try {
@@ -333,6 +333,23 @@ export class AgentOrchestrator {
333
333
  quotedTerms.every(t => q.toLowerCase().includes(t.toLowerCase())) ? q : `${q} ${quotedTerms.join(' ')}`.trim()
334
334
  );
335
335
  }
336
+ // A current-state plan is told to make its first query the bare entity
337
+ // name, and a small model often stops there. The bare query surfaces the
338
+ // live front page, which rarely states the fact asked for: "what does
339
+ // Southwest charge for a first checked bag" fetched southwest.com's home,
340
+ // booking and careers pages and answered that the fee is not stated
341
+ // (R20, 2026-09-07). Keep the entity query first — the domain vote and
342
+ // live-root promotion depend on it — and add the task's own words as a
343
+ // second query so the page that states the fact is in the queue too.
344
+ if (currentState && searchQueries.length === 1) {
345
+ try {
346
+ const { compactSearchTopic, clampSearchQuery } = await import('./ResearchOrchestrator.js');
347
+ const factQuery = clampSearchQuery(compactSearchTopic(prompt));
348
+ if (factQuery && factQuery.toLowerCase() !== searchQueries[0].toLowerCase()) {
349
+ searchQueries.push(factQuery);
350
+ }
351
+ } catch { /* the entity query alone is the pre-R20 behaviour */ }
352
+ }
336
353
 
337
354
  // ── GATHER (search) ───────────────────────────────────────────────────────
338
355
  const urlQueue = [...seedUrls]; // start with any user-provided seeds
@@ -389,7 +406,11 @@ export class AgentOrchestrator {
389
406
  // and never enters evidence.
390
407
  if (currentState && searchResults.length > 0) {
391
408
  const originCounts = new Map();
409
+ // Only the bare entity query votes: the fact query added above returns
410
+ // guides and news sites, which must not outvote the official domain.
411
+ const entityQuery = searchQueries[0];
392
412
  for (const s of searchResults) {
413
+ if (s.query !== entityQuery) continue;
393
414
  try {
394
415
  const origin = new URL(s.url).origin;
395
416
  originCounts.set(origin, (originCounts.get(origin) || 0) + 1);
@@ -536,7 +557,7 @@ export class AgentOrchestrator {
536
557
  // Short and imperative on purpose: the executing model is a small
537
558
  // local one (gemma3:4b-class) and ignores hedged phrasing.
538
559
  (currentState
539
- ? `- The task asks about the CURRENT state. Answer from the FIRST source below (the live page). NEVER present older or dated content as the current answer.\n`
560
+ ? `- The task asks about the CURRENT state. Answer from the FIRST source below (the live page); if the FIRST source does not state the answer, take it from the next sources in order. NEVER present older or dated content as the current answer.\n`
540
561
  : '') +
541
562
  `- Answer ONLY from the provided sources; do not use outside knowledge.\n` +
542
563
  `- Read the sources carefully before concluding anything is missing from them.\n` +
@@ -12,6 +12,7 @@ import { robotsPreflight, RobotsDisallowedError } from '../../utils/robotsGate.j
12
12
  import { throttleHost } from '../../utils/hostRateLimiter.js';
13
13
  import { CRAWLFORGE_USER_AGENT, identityHeaders } from '../../utils/fetchIdentity.js';
14
14
  import { pageTitle } from '../../utils/pageTitle.js';
15
+ import { extractMainContent, isThinMainContent } from '../../tools/scrape/_mainContent.js';
15
16
 
16
17
  const logger = new Logger('BFSCrawler');
17
18
 
@@ -391,9 +392,27 @@ export class BFSCrawler {
391
392
  // Extract title
392
393
  const title = pageTitle($) || $('h1').first().text().trim() || '';
393
394
 
394
- // Extract main content
395
+ // Extract main content. A page's chrome (header, mega-menu, footer)
396
+ // repeats on every page of a crawl and comes first in body order, so the
397
+ // 500-char preview showed the same "Explore Products" menu for all six
398
+ // Cessna pages, whose menu is plain <div>s outside any <nav> (R20,
399
+ // 2026-09-07). Use the same main-content pass scrape uses, fall back to
400
+ // the body minus its landmark chrome when Readability finds nothing or
401
+ // only a thin fragment, and to the whole body as a last resort.
395
402
  $('script, style, noscript').remove();
396
- const content = $('body').text().replace(/\s+/g, ' ').trim();
403
+ const squash = (text) => text.replace(/\s+/g, ' ').trim();
404
+ let content = '';
405
+ try {
406
+ const main = extractMainContent(html, url);
407
+ if (main.html && !isThinMainContent(main.html, html)) {
408
+ content = squash(load(main.html)('body').text());
409
+ }
410
+ } catch { /* fall through to the body */ }
411
+ if (!content) {
412
+ const $body = $('body').clone();
413
+ $body.find('header, nav, footer, aside, [role="navigation"], [role="banner"], [role="contentinfo"]').remove();
414
+ content = squash($body.text()) || squash($('body').text());
415
+ }
397
416
 
398
417
  // Extract metadata
399
418
  const metadata = {
@@ -30,6 +30,9 @@ export const INLINE_THRESHOLD_TOOLS = Object.freeze({
30
30
  extract_content: { textPaths: ['content.markdown', 'content.text', 'content.html', 'content.cleanedHTML'], truncate: true },
31
31
  crawl_deep: { textPaths: [], truncate: true },
32
32
  batch_scrape: { textPaths: [], truncate: true },
33
+ // A page of 25 markdown results is the same payload batch_scrape shapes;
34
+ // an async job's page came back as 111 KB whole (R20, 2026-09-07).
35
+ get_batch_results: { textPaths: [], truncate: true },
33
36
  stealth_mode: { textPaths: ['content.markdown', 'content.text', 'content.html'], truncate: true, when: (params) => params?.operation === 'scrape' },
34
37
  scrape_with_actions: { textPaths: ['content.markdown', 'content.text', 'content.html'], truncate: true },
35
38
  process_document: { textPaths: ['content.text'], truncate: true },
@@ -73,6 +73,8 @@ completed or in-progress job. Cheap (1 credit) because the batch was already
73
73
  paid for. Completed jobs are also exposed as `crawlforge://job/{jobId}`
74
74
  resources. Stored batch results share the local 1-hour result store that
75
75
  `read_result` reads, with the same eviction, so page through a batch within the hour.
76
+ Like `batch_scrape`, it takes `max_inline_chars` (default 40,000): a page over the
77
+ limit comes back as a `preview` plus a `result_handle` for `read_result`.
76
78
 
77
79
  ## scrape_with_actions — interact, then scrape (cost: 5)
78
80
 
@@ -13,6 +13,7 @@ import ExtractContentTool from '../extract/extractContent.js';
13
13
  import { stealthDocumentVerdict } from '../../utils/stealthVerdict.js';
14
14
  import { elementText } from '../../utils/elementText.js';
15
15
  import { pageTitle } from '../../utils/pageTitle.js';
16
+ import { htmlToMarkdown } from '../../utils/htmlToMarkdown.js';
16
17
 
17
18
  // Recording / replay helpers
18
19
  import {
@@ -726,6 +727,26 @@ export class ScrapeWithActionsTool extends EventEmitter {
726
727
  }
727
728
  }
728
729
 
730
+ // extractContent hands back text alone when Readability finds no
731
+ // article. support.southwest.com's help centre is a JS shell: after an
732
+ // 8 s wait the body text was there, `html` came back empty and
733
+ // `markdown` was the "Content not available" placeholder, reported as
734
+ // success (R20, 2026-09-07). The post-action DOM is in hand, so serve
735
+ // the requested formats from it instead of a placeholder.
736
+ if (chainResult?.finalHtml) {
737
+ extractResult.content = extractResult.content || {};
738
+ if (params.formats?.includes('markdown') && !extractResult.content.markdown) {
739
+ const bodyMarkdown = htmlToMarkdown(chainResult.finalHtml);
740
+ if (bodyMarkdown) {
741
+ extractResult.content.markdown = bodyMarkdown;
742
+ extractResult.content.markdownSource = 'body';
743
+ }
744
+ }
745
+ if (params.formats?.includes('html') && !extractResult.content.html) {
746
+ extractResult.content.html = chainResult.finalHtml;
747
+ }
748
+ }
749
+
729
750
  return extractResult;
730
751
  } catch (error) {
731
752
  this.log('warn', `Final content extraction failed: ${error.message}`);
@@ -42,7 +42,9 @@ export async function extractLinksHandler({ url, filter_external, base_url, user
42
42
  const href = $(element).attr('href');
43
43
  const text = $(element).text().trim();
44
44
 
45
- if (!href) return;
45
+ // A javascript: pseudo-link ("Cookie Settings") is a button, not a
46
+ // link; it was counted as an external link on boeing.com (R20).
47
+ if (!href || /^\s*javascript:/i.test(href)) return;
46
48
 
47
49
  try {
48
50
  const absoluteUrl = new URL(href, baseUrl).toString();
@@ -172,6 +172,22 @@ function isDataTable(table) {
172
172
  return rows >= 10 || columns > 4;
173
173
  }
174
174
 
175
+ /**
176
+ * A table whose author marked header cells carries data whatever its size —
177
+ * layout tables do not use <th>. The size test above misses every small fee
178
+ * or spec table: WestJet's checked-bag fees are 6 rows × 3 columns inside a
179
+ * `com-tabs` component (Readability's `negative` regex matches `com-`), and
180
+ * the page came back reading "fees are as follows:" with nothing following
181
+ * (R20, 2026-09-07).
182
+ * @param {HTMLTableElement} table
183
+ * @returns {boolean}
184
+ */
185
+ function isHeadedTable(table) {
186
+ if (table.rows.length < 2) return false;
187
+ if (!table.querySelector('th')) return false;
188
+ return Array.from(table.rows).some((row) => row.cells.length >= 2);
189
+ }
190
+
175
191
  /**
176
192
  * A data table's text with its structure kept: one line per row, cells joined
177
193
  * by " | ". A bare `textContent` runs every cell together
@@ -216,7 +232,7 @@ export function recoverDroppedTables(html, url, keptText = '') {
216
232
  // A nested table travels with its parent; re-attaching it separately
217
233
  // would duplicate it.
218
234
  .filter((table) => !table.parentElement?.closest('table'))
219
- .filter(isDataTable)
235
+ .filter((table) => isDataTable(table) || isHeadedTable(table))
220
236
  .filter((table) => {
221
237
  const signature = normalizeWhitespace(table.textContent || '').slice(0, SIGNATURE_LENGTH);
222
238
  return signature.length > 0 && !kept.includes(signature);
@@ -373,7 +373,18 @@ export class RedditSearchTool {
373
373
  // caller the 422. Thread mode returned above, so both remaining modes
374
374
  // (posts, comments) narrow.
375
375
  const narrowable = Boolean(v.query) && !v.after;
376
- if (!error.retryable || !narrowable) throw error;
376
+ if (!error.retryable || !narrowable) {
377
+ // The caller's window is respected, so the 422 is theirs to act on —
378
+ // but the generic hint ("add subreddit or author") is useless on a
379
+ // search that is already scoped (R20: r/aviation "737 MAX" after=30d).
380
+ if (error.retryable && v.query && v.after) {
381
+ throw new Error(
382
+ `${error.message} — Arctic Shift timed out searching your after=${v.after} window. ` +
383
+ 'Pass a narrower after (7d, 3d, 1d), drop the query to list the newest posts in the scope, or read one post with mode:"thread" and link_id.'
384
+ );
385
+ }
386
+ throw error;
387
+ }
377
388
  let lastError = error;
378
389
  for (const window of ['7d', '3d', '1d']) {
379
390
  await new Promise((resolve) => setTimeout(resolve, this.retryDelayMs));
@@ -16,10 +16,42 @@
16
16
 
17
17
  import TurndownService from 'turndown';
18
18
  import { gfm } from 'turndown-plugin-gfm';
19
+ import { load } from 'cheerio';
19
20
  import { stripHiddenHtml } from './hiddenContent.js';
20
21
 
21
22
  let _td = null;
22
23
 
24
+ /**
25
+ * turndown-plugin-gfm renders a table as a pipe table only when its first row
26
+ * is entirely <th>. A corner cell written as an empty <td> — WestJet's fee
27
+ * table opens `<td> </td><th>1st Bag</th><th>2nd Bag</th>` — fails that test,
28
+ * so the whole table fell to the layout-table rule and flattened to text
29
+ * lines with the columns lost (R20, 2026-09-07). Promote empty corner cells
30
+ * in an otherwise all-<th> first row so the table renders as a table.
31
+ * @param {string} html
32
+ * @returns {string}
33
+ */
34
+ export function promoteCornerHeaderCells(html) {
35
+ if (!/<th[\s>]/i.test(html)) return html;
36
+ try {
37
+ const $ = load(html);
38
+ let changed = false;
39
+ $('table').each((_, table) => {
40
+ const firstRow = $(table).find('tr').first();
41
+ if (!firstRow.length || firstRow.closest('table')[0] !== table) return;
42
+ const cells = firstRow.children('th, td');
43
+ const tds = cells.filter('td');
44
+ if (cells.filter('th').length === 0 || tds.length === 0) return;
45
+ if (tds.toArray().some((td) => $(td).text().trim() !== '')) return;
46
+ tds.each((__, td) => { td.name = 'th'; });
47
+ changed = true;
48
+ });
49
+ return changed ? $.html() : html;
50
+ } catch {
51
+ return html;
52
+ }
53
+ }
54
+
23
55
  // Mirrors turndown-plugin-gfm's own heading-row test, which is what decides
24
56
  // whether it converts a table or keeps it as raw HTML.
25
57
  function isHeadingRow(tr) {
@@ -112,7 +144,7 @@ export function htmlToMarkdown(html, options = {}) {
112
144
  const visible = options.keepHiddenContent
113
145
  ? html
114
146
  : stripHiddenHtml(html, { css: options.css });
115
- return getTurndown().turndown(visible).trim();
147
+ return getTurndown().turndown(promoteCornerHeaderCells(visible)).trim();
116
148
  } catch {
117
149
  // Fallback: strip tags, return plain text
118
150
  return html.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
@@ -10,6 +10,23 @@ import { noteRetryAfter } from './hostRateLimiter.js';
10
10
 
11
11
  const gunzip = promisify(zlib.gunzip);
12
12
 
13
+ /**
14
+ * A sitemap <loc> resolved against the sitemap's own URL and normalized;
15
+ * null when it is empty or not a URL. The protocol wants absolute locs, but
16
+ * boeing.com's 1,878-entry sitemap is written with relative paths ("/",
17
+ * "/commercial"): `normalizeUrl("/")` threw out of the entry loop, the whole
18
+ * sitemap read as empty, and map_site fell back to crawling links — 75 URLs
19
+ * (R20, 2026-09-07). One bad entry must not discard the rest either.
20
+ */
21
+ function resolveLoc(loc, base) {
22
+ if (!loc) return null;
23
+ try {
24
+ return normalizeUrl(new URL(loc, base).href);
25
+ } catch {
26
+ return null;
27
+ }
28
+ }
29
+
13
30
  export class SitemapParser {
14
31
  constructor(options = {}) {
15
32
  const {
@@ -121,11 +138,11 @@ export class SitemapParser {
121
138
  // Parse sitemap index entries
122
139
  $('sitemap').each((_, element) => {
123
140
  const $sitemap = $(element);
124
- const loc = $sitemap.find('loc').text().trim();
125
-
141
+ const loc = resolveLoc($sitemap.find('loc').text().trim(), indexUrl);
142
+
126
143
  if (loc) {
127
144
  const sitemap = {
128
- url: normalizeUrl(loc),
145
+ url: loc,
129
146
  lastmod: $sitemap.find('lastmod').text().trim() || null
130
147
  };
131
148
  sitemaps.push(sitemap);
@@ -347,7 +364,9 @@ export class SitemapParser {
347
364
  const cacheKey = this.cache?.generateKey(url, { depth: currentDepth });
348
365
  if (this.cache && cacheKey) {
349
366
  const cached = await this.cache.get(cacheKey);
350
- if (cached) {
367
+ // An empty cached parse is a failure that got remembered (see the
368
+ // write side below); re-parse rather than serve it for an hour.
369
+ if (cached && (cached.urls?.length > 0 || cached.sitemaps?.length > 0)) {
351
370
  this.stats.cacheHits++;
352
371
  return cached;
353
372
  }
@@ -378,8 +397,12 @@ export class SitemapParser {
378
397
  }
379
398
  }
380
399
 
381
- // Cache the result
382
- if (this.cache && cacheKey) {
400
+ // Cache the result — but never an empty one. A parse that yielded no
401
+ // URL and no child sitemap is far more likely a failure than a fact
402
+ // (the relative-<loc> throw above sat in the disk cache for an hour
403
+ // and map_site kept answering 75 for boeing.com after the parser was
404
+ // fixed, R20 2026-09-07).
405
+ if (this.cache && cacheKey && (result.urls.length > 0 || result.sitemaps.length > 0)) {
383
406
  await this.cache.set(cacheKey, result);
384
407
  }
385
408
 
@@ -469,11 +492,11 @@ export class SitemapParser {
469
492
  // Parse standard URLs
470
493
  $('url').each((_, element) => {
471
494
  const $url = $(element);
472
- const loc = $url.find('loc').text().trim();
473
-
495
+ const loc = resolveLoc($url.find('loc').text().trim(), url);
496
+
474
497
  if (loc && result.urls.length < this.maxUrlsPerSitemap) {
475
498
  const urlData = {
476
- loc: normalizeUrl(loc),
499
+ loc,
477
500
  lastmod: $url.find('lastmod').text().trim() || null,
478
501
  changefreq: $url.find('changefreq').text().trim() || null,
479
502
  priority: $url.find('priority').text().trim() || null
@@ -565,10 +588,10 @@ export class SitemapParser {
565
588
 
566
589
  $('sitemap').each((_, element) => {
567
590
  const $sitemap = $(element);
568
- const loc = $sitemap.find('loc').text().trim();
569
-
591
+ const loc = resolveLoc($sitemap.find('loc').text().trim(), url);
592
+
570
593
  if (loc) {
571
- result.sitemaps.push(normalizeUrl(loc));
594
+ result.sitemaps.push(loc);
572
595
  }
573
596
  });
574
597