@webcrawl/mcp 1.4.3 → 1.4.4

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.
@@ -89,11 +89,15 @@ export async function fetchHedgedNativeFederation(rawQuery, localizedQuery, perE
89
89
  },
90
90
  wikipedia: {
91
91
  primary: false,
92
- run: () => fetchWikipediaOpenSearch(localizedQuery, 2),
92
+ run: () => fetchWikipediaOpenSearch(localizedQuery, Math.min(perEngineLimit, 5)),
93
93
  },
94
94
  "hn-algolia": {
95
95
  primary: false,
96
- run: () => fetchHackerNewsAlgolia(localizedQuery, 5),
96
+ run: () => fetchHackerNewsAlgolia(localizedQuery, perEngineLimit),
97
+ },
98
+ "bing-rss": {
99
+ primary: false,
100
+ run: () => fetchBingWebRss(localizedQuery, perEngineLimit),
97
101
  },
98
102
  arxiv: {
99
103
  primary: category === "academic",
@@ -159,14 +163,17 @@ export async function fetchHedgedNativeFederation(rawQuery, localizedQuery, perE
159
163
  resolve();
160
164
  return;
161
165
  }
162
- // Early-exit quorum: at least 1 primary web engine + 2 total engines + enough candidates (and shopping engine finished when in shopping plan)
166
+ const uniqueCount = new Set(collected.map((c) => canonicalSearchUrl(c.url)).filter(Boolean)).size;
167
+ const fillTarget = Math.max(1, req.limit ?? 5);
168
+ // Early-exit quorum: at least 1 primary web engine + 2 total engines + enough
169
+ // *unique* URLs to fill `limit` (not just Wikipedia's 2 hits).
163
170
  if (completedShoppingEngine &&
164
171
  completedNewsEngine &&
165
172
  completedNvdEngine &&
166
173
  completedArxivEngine &&
167
174
  completedPrimaryEngines >= 1 &&
168
175
  totalCompletedEngines >= 2 &&
169
- collected.length >= perEngineLimit &&
176
+ uniqueCount >= fillTarget &&
170
177
  !graceTimer) {
171
178
  graceTimer = setTimeout(() => {
172
179
  if (!settled) {
@@ -209,7 +216,7 @@ export async function fetchHedgedNativeFederation(rawQuery, localizedQuery, perE
209
216
  }
210
217
  });
211
218
  // Fallback: the free engines are scraped and can be blocked or rate limited. When they
212
- // come back thin, fill in from Jina (paid) so the caller still gets an answer.
219
+ // come back thin, fill in from a keyed search provider so the caller still gets an answer.
213
220
  const uniqueUrls = new Set(collected.map((c) => c.url).filter(Boolean));
214
221
  if (uniqueUrls.size < JINA_FALLBACK_MIN_URLS && process.env.JINA_API_KEY) {
215
222
  const t0 = performance.now();
@@ -220,14 +227,17 @@ export async function fetchHedgedNativeFederation(rawQuery, localizedQuery, perE
220
227
  collected.push(...jina);
221
228
  }
222
229
  // HTML SERPs (DDG, Brave) are frequently challenge/429 pages. When they
223
- // yield nothing, fall back to keyless APIs that still work without a key.
224
- if (collected.length === 0 && (!req.provider || req.provider === "auto")) {
225
- const extras = await fetchKeylessSearchFallbacks(localizedQuery, perEngineLimit);
230
+ // yield nothing *or come back thinner than `limit`*, fill from keyless APIs
231
+ // (Wikipedia, HN Algolia, Bing RSS, DDG Instant Answer) with retry/backoff.
232
+ const fillTarget = Math.max(1, req.limit ?? 5);
233
+ const uniqueAfter = new Set(collected.map((c) => canonicalSearchUrl(c.url)).filter(Boolean));
234
+ if (uniqueAfter.size < fillTarget && (!req.provider || req.provider === "auto")) {
235
+ const extras = await fetchKeylessSearchFallbacks(localizedQuery, Math.max(fillTarget, perEngineLimit));
226
236
  collected.push(...extras);
227
237
  }
228
238
  return collected;
229
239
  }
230
- /** Below this many unique URLs from the free engines, the Jina fallback runs. */
240
+ /** Below this many unique URLs from the free engines, the keyed search fallback runs. */
231
241
  export const JINA_FALLBACK_MIN_URLS = 3;
232
242
  /** True when a scraped SERP is a bot-check / captcha rather than results. */
233
243
  export function isSerpChallengeHtml(html, status = 200) {
@@ -242,8 +252,64 @@ export function isSerpChallengeHtml(html, status = 200) {
242
252
  lower.includes("enable javascript to function"));
243
253
  }
244
254
  export const EMPTY_SEARCH_ERROR = "Keyless search engines returned no results (DuckDuckGo and Brave often serve a bot challenge). " +
245
- "Tried Wikipedia, Hacker News, and Bing RSS as well. " +
255
+ "Tried Wikipedia, Hacker News, Bing RSS, and DuckDuckGo Instant Answer as well. " +
246
256
  "Set BRAVE_SEARCH_API_KEY, SERPER_API_KEY, or JINA_API_KEY, or retry later.";
257
+ /** Canonical URL for search dedupe (host lowercased, trailing slash / tracking params stripped). */
258
+ export function canonicalSearchUrl(raw) {
259
+ if (!raw)
260
+ return "";
261
+ try {
262
+ const u = new URL(raw);
263
+ u.hash = "";
264
+ u.hostname = u.hostname.toLowerCase();
265
+ for (const key of [...u.searchParams.keys()]) {
266
+ if (/^(utm_|ref|fbclid|gclid|mc_)/i.test(key))
267
+ u.searchParams.delete(key);
268
+ }
269
+ let path = u.pathname.replace(/\/+$/, "") || "/";
270
+ u.pathname = path;
271
+ return u.toString();
272
+ }
273
+ catch {
274
+ return raw.replace(/\/+$/, "").toLowerCase();
275
+ }
276
+ }
277
+ /** Round-robin merge so one source (e.g. Wikipedia) cannot fill `-n` by itself. */
278
+ export function mergeSearchCandidates(groups, limit) {
279
+ const seen = new Set();
280
+ const out = [];
281
+ const queues = groups.map((g) => [...g]);
282
+ let progressed = true;
283
+ while (out.length < limit && progressed) {
284
+ progressed = false;
285
+ for (const q of queues) {
286
+ while (q.length > 0) {
287
+ const item = q.shift();
288
+ const key = canonicalSearchUrl(item.url);
289
+ if (!key || seen.has(key))
290
+ continue;
291
+ seen.add(key);
292
+ out.push(item);
293
+ progressed = true;
294
+ break;
295
+ }
296
+ if (out.length >= limit)
297
+ break;
298
+ }
299
+ }
300
+ return out;
301
+ }
302
+ export async function withRetry(fn, opts = {}) {
303
+ const retries = opts.retries ?? 1;
304
+ const delayMs = opts.delayMs ?? 250;
305
+ const isEmpty = opts.isEmpty ?? ((v) => (Array.isArray(v) ? v.length === 0 : !v));
306
+ let last = await fn();
307
+ for (let i = 0; i < retries && isEmpty(last); i++) {
308
+ await new Promise((r) => setTimeout(r, delayMs * (i + 1)));
309
+ last = await fn();
310
+ }
311
+ return last;
312
+ }
247
313
  /** Parse Bing's `format=rss` web-search feed into candidate docs. */
248
314
  export function parseBingWebRss(xml, limit) {
249
315
  const items = [];
@@ -296,27 +362,83 @@ async function fetchBingWebRss(query, limit) {
296
362
  }
297
363
  }
298
364
  /**
299
- * Last-resort keyless engines used when DDG/Brave HTML SERPs come back empty.
300
- * Wikipedia OpenSearch, HN Algolia, and Bing RSS do not require a JS challenge.
365
+ * Last-resort keyless engines used when DDG/Brave HTML SERPs come back empty
366
+ * or thinner than `limit`. Wikipedia OpenSearch, HN Algolia, Bing RSS, and
367
+ * DuckDuckGo Instant Answer do not require a JS challenge or API key.
301
368
  */
302
369
  export async function fetchKeylessSearchFallbacks(query, limit) {
303
370
  const per = Math.max(limit, 5);
304
- const [hn, wiki, bing] = await Promise.all([
305
- fetchHackerNewsAlgolia(query, per),
306
- fetchWikipediaOpenSearch(query, per),
307
- fetchBingWebRss(query, per),
371
+ const empty = (v) => v.length === 0;
372
+ const run = () => Promise.all([
373
+ withRetry(() => fetchHackerNewsAlgolia(query, per), { retries: 1, delayMs: 280, isEmpty: empty }),
374
+ withRetry(() => fetchWikipediaOpenSearch(query, per), { retries: 1, delayMs: 280, isEmpty: empty }),
375
+ withRetry(() => fetchBingWebRss(query, per), { retries: 1, delayMs: 280, isEmpty: empty }),
376
+ withRetry(() => fetchDuckDuckGoInstantAnswer(query, per), { retries: 1, delayMs: 280, isEmpty: empty }),
308
377
  ]);
309
- const seen = new Set();
310
- const out = [];
311
- for (const item of [...hn, ...wiki, ...bing]) {
312
- if (!item.url || seen.has(item.url))
313
- continue;
314
- seen.add(item.url);
315
- out.push(item);
316
- if (out.length >= Math.max(limit + 4, 8))
317
- break;
378
+ let groups = await run();
379
+ if (groups.every((g) => g.length === 0)) {
380
+ await new Promise((r) => setTimeout(r, 400));
381
+ groups = await run();
382
+ }
383
+ return mergeSearchCandidates(groups, Math.max(limit, 8));
384
+ }
385
+ /** DuckDuckGo Instant Answer API — keyless JSON, unrelated to the HTML SERP challenge. */
386
+ export async function fetchDuckDuckGoInstantAnswer(query, limit) {
387
+ try {
388
+ const url = `https://api.duckduckgo.com/?q=${encodeURIComponent(query)}&format=json&no_redirect=1&no_html=1&skip_disambig=1`;
389
+ const res = await fetch(url, {
390
+ headers: {
391
+ Accept: "application/json",
392
+ "User-Agent": "WebCrawl/1.4 (https://webcrawl.app; search@webcrawl.app)",
393
+ },
394
+ signal: AbortSignal.timeout(2500),
395
+ });
396
+ if (!res.ok)
397
+ return [];
398
+ const json = (await res.json());
399
+ const items = [];
400
+ const push = (title, href, description) => {
401
+ if (!href.startsWith("http") || items.length >= limit)
402
+ return;
403
+ if (items.some((i) => canonicalSearchUrl(i.url) === canonicalSearchUrl(href)))
404
+ return;
405
+ items.push({
406
+ title: title || href,
407
+ url: href,
408
+ description: description || title,
409
+ engine: "ddg-lite",
410
+ engineRank: items.length + 1,
411
+ });
412
+ };
413
+ if (json.AbstractURL && (json.Heading || json.AbstractText)) {
414
+ push(json.Heading || json.AbstractSource || json.AbstractURL, json.AbstractURL, json.AbstractText || "");
415
+ }
416
+ for (const row of json.Results ?? []) {
417
+ if (row.FirstURL && row.Text)
418
+ push(row.Text, row.FirstURL, row.Text);
419
+ }
420
+ const walk = (node) => {
421
+ if (items.length >= limit)
422
+ return;
423
+ if (Array.isArray(node)) {
424
+ for (const n of node)
425
+ walk(n);
426
+ return;
427
+ }
428
+ if (!node || typeof node !== "object")
429
+ return;
430
+ const rec = node;
431
+ if (rec.FirstURL && rec.Text)
432
+ push(rec.Text, rec.FirstURL, rec.Text);
433
+ if (rec.Topics)
434
+ walk(rec.Topics);
435
+ };
436
+ walk(json.RelatedTopics);
437
+ return items;
438
+ }
439
+ catch {
440
+ return [];
318
441
  }
319
- return out;
320
442
  }
321
443
  export async function fetchJinaSearch(query, limit, req) {
322
444
  const key = process.env.JINA_API_KEY;
@@ -639,17 +761,33 @@ async function fetchDuckDuckGoLiteOnly(query, limit, userLocation) {
639
761
  const countryCode = (userLocation?.country || "").toUpperCase();
640
762
  const kl = ddgRegion(countryCode, undefined);
641
763
  const klParam = kl ? `&kl=${encodeURIComponent(kl)}` : "";
642
- const liteRes = await fetchWithTlsImpersonation(`https://lite.duckduckgo.com/lite/?q=${encodeURIComponent(query)}${klParam}`, 2200);
643
- if (liteRes && liteRes.html && !isSerpChallengeHtml(liteRes.html, liteRes.status)) {
644
- const html = liteRes.html;
645
- const linkMatches = Array.from(html.matchAll(/<a\s+[^>]*href=["']([^"']+)["'][^>]*class=["']result-link["'][^>]*>([\s\S]*?)<\/a>|<a\s+[^>]*class=["']result-link["'][^>]*href=["']([^"']+)["'][^>]*>([\s\S]*?)<\/a>/gi));
646
- const snippetMatches = Array.from(html.matchAll(/<td\s+[^>]*class=["']result-snippet["'][^>]*>([\s\S]*?)<\/td>/gi));
647
- for (let i = 0; i < linkMatches.length && items.length < limit; i++) {
648
- const m = linkMatches[i];
649
- const rawUrl = m[1] || m[3] || "";
650
- const rawTitle = m[2] || m[4] || "";
651
- const rawSnippet = snippetMatches[i]?.[1] || "";
652
- addResult(rawTitle, rawUrl, rawSnippet);
764
+ const endpoints = [
765
+ `https://lite.duckduckgo.com/lite/?q=${encodeURIComponent(query)}${klParam}`,
766
+ `https://html.duckduckgo.com/html/?q=${encodeURIComponent(query)}${klParam}`,
767
+ ];
768
+ for (const endpoint of endpoints) {
769
+ if (items.length >= limit)
770
+ break;
771
+ try {
772
+ const liteRes = await fetchWithTlsImpersonation(endpoint, 2200);
773
+ if (!liteRes?.html || isSerpChallengeHtml(liteRes.html, liteRes.status)) {
774
+ continue;
775
+ }
776
+ const html = liteRes.html;
777
+ const linkMatches = Array.from(html.matchAll(/<a\s+[^>]*href=["']([^"']+)["'][^>]*class=["']result-link["'][^>]*>([\s\S]*?)<\/a>|<a\s+[^>]*class=["']result-link["'][^>]*href=["']([^"']+)["'][^>]*>([\s\S]*?)<\/a>/gi));
778
+ const snippetMatches = Array.from(html.matchAll(/<td\s+[^>]*class=["']result-snippet["'][^>]*>([\s\S]*?)<\/td>/gi));
779
+ for (let i = 0; i < linkMatches.length && items.length < limit; i++) {
780
+ const m = linkMatches[i];
781
+ const rawUrl = m[1] || m[3] || "";
782
+ const rawTitle = m[2] || m[4] || "";
783
+ const rawSnippet = snippetMatches[i]?.[1] || "";
784
+ addResult(rawTitle, rawUrl, rawSnippet);
785
+ }
786
+ if (items.length > 0)
787
+ break;
788
+ }
789
+ catch {
790
+ // try the next DDG HTML endpoint
653
791
  }
654
792
  }
655
793
  }
@@ -724,7 +862,7 @@ async function fetchWikipediaOpenSearch(query, limit) {
724
862
  "User-Agent": "WebCrawl/1.0 (https://webcrawl.app; search@webcrawl.app)",
725
863
  Accept: "application/json",
726
864
  },
727
- signal: AbortSignal.timeout(1400),
865
+ signal: AbortSignal.timeout(2200),
728
866
  });
729
867
  if (!res.ok) {
730
868
  return [];
@@ -761,7 +899,7 @@ async function fetchHackerNewsAlgolia(query, limit) {
761
899
  const url = `https://hn.algolia.com/api/v1/search?query=${encodeURIComponent(cleanQuery)}&tags=story&hitsPerPage=${limit}`;
762
900
  const res = await fetch(url, {
763
901
  headers: { Accept: "application/json" },
764
- signal: AbortSignal.timeout(1400),
902
+ signal: AbortSignal.timeout(2200),
765
903
  });
766
904
  if (!res.ok) {
767
905
  return [];
@@ -62,7 +62,7 @@ export const pdfOfficeTargetPlugin = {
62
62
  name: "PDFs & Office Documents (DOCX, XLSX, PPTX)",
63
63
  category: "documents",
64
64
  returns: "Page-numbered Markdown, tables, native text extraction, automatic OCR on scanned pages, and skipped-page diagnostics",
65
- requirements: "Built-in @firecrawl/pdf-inspector, mammoth, and officeparser; optional OCR provider for scanned pages",
65
+ requirements: "Built-in PDF inspector, mammoth, and officeparser; optional OCR provider for scanned pages",
66
66
  examples: [
67
67
  "npx webcrawl scrape https://arxiv.org/pdf/1706.03762.pdf",
68
68
  ],
package/dist/server.js CHANGED
@@ -164,6 +164,9 @@ export function createWebcrawlMcpServer(client, versionOrOpts = "0.1.0") {
164
164
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
165
165
  }, ({ url, prompt, schema }) => run(async () => {
166
166
  const res = await client.extract({ url, prompt, schema });
167
+ if (res.success === false && typeof res.error === "string") {
168
+ throw new Error(res.error + formatCredits(res));
169
+ }
167
170
  return JSON.stringify(res.data ?? res, null, 2) + formatCredits(res);
168
171
  }));
169
172
  server.registerTool("web_rerank", {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webcrawl/mcp",
3
- "version": "1.4.3",
3
+ "version": "1.4.4",
4
4
  "mcpName": "io.github.brainfish-ai/webcrawl",
5
5
  "description": "MCP server for Webcrawl Anything: search, scrape, map, crawl, parse and monitor the web from your agent. Runs locally with no API key, or on webcrawl.app cloud.",
6
6
  "type": "module",
@@ -56,8 +56,8 @@
56
56
  "@firecrawl/pdf-inspector": "^1.25.2",
57
57
  "@modelcontextprotocol/sdk": "^1.32.2",
58
58
  "@vercel/sandbox": "^1.0.0",
59
- "@webcrawl/client": "^1.4.3",
60
- "@webcrawl/curl-impersonate": "^1.4.3",
59
+ "@webcrawl/client": "^1.4.4",
60
+ "@webcrawl/curl-impersonate": "^1.4.4",
61
61
  "ai": "^7.0.150",
62
62
  "impit": "^0.14.7",
63
63
  "mammoth": "^1.13.0",