mcp-searxng 1.6.0 → 1.7.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/README.md CHANGED
@@ -273,6 +273,21 @@ You should receive a JSON response. If not, confirm the file is correctly mounte
273
273
 
274
274
  See also: [SearXNG settings docs](https://docs.searxng.org/admin/settings/settings.html) · [discussion](https://github.com/searxng/searxng/discussions/1789)
275
275
 
276
+ ### Can't enable JSON? (HTML fallback)
277
+
278
+ If you must use a public instance you don't control and it rejects `format=json` (the 403 above), set the opt-in flag instead of editing the server:
279
+
280
+ ```json
281
+ "SEARXNG_HTML_FALLBACK": "true"
282
+ ```
283
+
284
+ A search that gets a `403`/`404` or a non-JSON response is then retried automatically **without** `format=json` and parsed from the regular HTML results page.
285
+
286
+ - **On success:** you get normal results (title, URL, snippet). They are marked `sourceFormat: "html"` in JSON mode, and text mode adds the line *"Note: Results parsed from SearXNG HTML fallback; metadata is limited."* Relevance scores and engine names are not available from HTML.
287
+ - **On failure:** parsing is best-effort and varies by the instance's theme/version, so some results may be missed or sparse. If the HTML page itself also fails — still blocked, rate-limited (`429`), auth (`401`), or `5xx` — the **original error is surfaced unchanged**. The fallback only triggers on `403`/`404`/non-JSON, never on auth or network errors.
288
+
289
+ Enabling JSON on an instance you control (above) remains the recommended setup — the fallback is a compatibility aid, not a replacement.
290
+
276
291
  ## Contributing
277
292
 
278
293
  See [CONTRIBUTING.md](CONTRIBUTING.md)
package/dist/search.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { parse } from "node-html-parser";
1
2
  import { getKnownCategories, getKnownEngines } from "./instance-info.js";
2
3
  import { createProxyAgent, createDefaultAgent, ProxyType } from "./proxy.js";
3
4
  import { logMessage } from "./logging.js";
@@ -32,6 +33,102 @@ function truncateResultContent(content, maxResultChars) {
32
33
  }
33
34
  return `${content.slice(0, maxResultChars)}…`;
34
35
  }
36
+ function normalizeHtmlText(text) {
37
+ return text.replace(/\s+/g, " ").trim();
38
+ }
39
+ function isHtmlFallbackEnabled() {
40
+ return process.env.SEARXNG_HTML_FALLBACK === "true";
41
+ }
42
+ function shouldFallbackForStatus(status) {
43
+ return status === 403 || status === 404;
44
+ }
45
+ function buildHtmlFallbackUrl(jsonUrl) {
46
+ const htmlUrl = new URL(jsonUrl.toString());
47
+ htmlUrl.searchParams.delete("format");
48
+ return htmlUrl;
49
+ }
50
+ function parseHtmlSearchResults(html, query) {
51
+ const root = parse(html);
52
+ const articles = root.querySelectorAll("article.result");
53
+ const candidates = articles.length > 0 ? articles : root.querySelectorAll(".result");
54
+ const results = candidates
55
+ .map((entry) => {
56
+ const link = entry.querySelector("h3 > a") ?? entry.querySelector("h3 a") ?? entry.querySelector("a[href]");
57
+ if (!link) {
58
+ return undefined;
59
+ }
60
+ const href = link?.getAttribute("href")?.trim();
61
+ if (!href) {
62
+ return undefined;
63
+ }
64
+ try {
65
+ new URL(href);
66
+ }
67
+ catch {
68
+ return undefined;
69
+ }
70
+ const title = normalizeHtmlText(link.text);
71
+ const snippetNode = entry.querySelector("p.content") ?? entry.querySelector(".content");
72
+ const content = snippetNode ? normalizeHtmlText(snippetNode.text) : "";
73
+ return {
74
+ title,
75
+ url: href,
76
+ content,
77
+ };
78
+ })
79
+ .filter((result) => result !== undefined);
80
+ return {
81
+ query,
82
+ number_of_results: results.length,
83
+ results,
84
+ sourceFormat: "html",
85
+ };
86
+ }
87
+ async function fetchWithSearchTimeout(mcpServer, url, requestOptions, timeoutMs, query, searxngUrl) {
88
+ const controller = new AbortController();
89
+ const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
90
+ try {
91
+ logMessage(mcpServer, "info", `Making request to: ${url.toString()}`);
92
+ return await fetch(url.toString(), {
93
+ ...requestOptions,
94
+ signal: controller.signal,
95
+ });
96
+ }
97
+ catch (error) {
98
+ logMessage(mcpServer, "error", `Network error during search request: ${error.message}`, { query, url: url.toString() });
99
+ const context = {
100
+ url: url.toString(),
101
+ searxngUrl,
102
+ proxyAgent: !!requestOptions.dispatcher,
103
+ username: process.env.AUTH_USERNAME,
104
+ };
105
+ throw createNetworkError(error, context);
106
+ }
107
+ finally {
108
+ clearTimeout(timeoutId);
109
+ }
110
+ }
111
+ async function fetchHtmlFallbackSearch(mcpServer, jsonUrl, requestOptions, timeoutMs, query, searxngUrl) {
112
+ const htmlUrl = buildHtmlFallbackUrl(jsonUrl);
113
+ logMessage(mcpServer, "info", `Retrying search with HTML fallback: ${htmlUrl.toString()}`);
114
+ const response = await fetchWithSearchTimeout(mcpServer, htmlUrl, requestOptions, timeoutMs, query, searxngUrl);
115
+ if (!response.ok) {
116
+ let responseBody;
117
+ try {
118
+ responseBody = await response.text();
119
+ }
120
+ catch {
121
+ responseBody = '[Could not read response body]';
122
+ }
123
+ const context = {
124
+ url: htmlUrl.toString(),
125
+ searxngUrl,
126
+ };
127
+ throw createServerError(response.status, response.statusText, responseBody, context);
128
+ }
129
+ const html = await response.text();
130
+ return parseHtmlSearchResults(html, query);
131
+ }
35
132
  function hasItems(items) {
36
133
  return Array.isArray(items) && items.length > 0;
37
134
  }
@@ -278,57 +375,49 @@ export async function performWebSearch(mcpServer, query, pageno = 1, time_range,
278
375
  }
279
376
  // Fetch with AbortController timeout and enhanced error handling
280
377
  const SEARCH_TIMEOUT_MS = parseInt(process.env.SEARXNG_TIMEOUT_MS ?? "10000", 10);
281
- const controller = new AbortController();
282
- const timeoutId = setTimeout(() => controller.abort(), SEARCH_TIMEOUT_MS);
283
378
  let response;
284
- try {
285
- logMessage(mcpServer, "info", `Making request to: ${url.toString()}`);
286
- response = await fetch(url.toString(), {
287
- ...requestOptions,
288
- signal: controller.signal,
289
- });
290
- }
291
- catch (error) {
292
- clearTimeout(timeoutId);
293
- logMessage(mcpServer, "error", `Network error during search request: ${error.message}`, { query, url: url.toString() });
294
- const context = {
295
- url: url.toString(),
296
- searxngUrl,
297
- proxyAgent: !!dispatcher,
298
- username
299
- };
300
- throw createNetworkError(error, context);
301
- }
302
- clearTimeout(timeoutId);
379
+ response = await fetchWithSearchTimeout(mcpServer, url, requestOptions, SEARCH_TIMEOUT_MS, query, searxngUrl);
380
+ let data;
303
381
  if (!response.ok) {
304
- let responseBody;
305
- try {
306
- responseBody = await response.text();
382
+ if (isHtmlFallbackEnabled() && shouldFallbackForStatus(response.status)) {
383
+ data = await fetchHtmlFallbackSearch(mcpServer, url, requestOptions, SEARCH_TIMEOUT_MS, query, searxngUrl);
307
384
  }
308
- catch {
309
- responseBody = '[Could not read response body]';
385
+ else {
386
+ let responseBody;
387
+ try {
388
+ responseBody = await response.text();
389
+ }
390
+ catch {
391
+ responseBody = '[Could not read response body]';
392
+ }
393
+ const context = {
394
+ url: url.toString(),
395
+ searxngUrl
396
+ };
397
+ throw createServerError(response.status, response.statusText, responseBody, context);
310
398
  }
311
- const context = {
312
- url: url.toString(),
313
- searxngUrl
314
- };
315
- throw createServerError(response.status, response.statusText, responseBody, context);
316
- }
317
- // Parse JSON response
318
- let data;
319
- try {
320
- data = (await response.json());
321
399
  }
322
- catch (error) {
323
- let responseText;
400
+ else {
401
+ // Parse JSON response
324
402
  try {
325
- responseText = await response.text();
403
+ data = (await response.json());
326
404
  }
327
- catch {
328
- responseText = '[Could not read response text]';
405
+ catch (error) {
406
+ if (isHtmlFallbackEnabled()) {
407
+ data = await fetchHtmlFallbackSearch(mcpServer, url, requestOptions, SEARCH_TIMEOUT_MS, query, searxngUrl);
408
+ }
409
+ else {
410
+ let responseText;
411
+ try {
412
+ responseText = await response.text();
413
+ }
414
+ catch {
415
+ responseText = '[Could not read response text]';
416
+ }
417
+ const context = { url: url.toString() };
418
+ throw createJSONError(responseText, context);
419
+ }
329
420
  }
330
- const context = { url: url.toString() };
331
- throw createJSONError(responseText, context);
332
421
  }
333
422
  if (!data.results) {
334
423
  const context = { url: url.toString(), query };
@@ -349,6 +438,7 @@ export async function performWebSearch(mcpServer, query, pageno = 1, time_range,
349
438
  const metadata = formatSearchMetadata(data);
350
439
  const leadingSections = [
351
440
  filters.validationNote ?? null,
441
+ data.sourceFormat === "html" ? "Note: Results parsed from SearXNG HTML fallback; metadata is limited." : null,
352
442
  metadata || null,
353
443
  ].filter(Boolean).join("\n\n");
354
444
  if (slicedResults.length === 0) {
@@ -365,8 +455,15 @@ export async function performWebSearch(mcpServer, query, pageno = 1, time_range,
365
455
  logMessage(mcpServer, "info", `Search completed: "${query}" (${searchParams}) - ${slicedResults.length} results in ${duration}ms`);
366
456
  const formattedResults = slicedResults
367
457
  .map((r) => {
368
- const score = r.score || 0;
369
- return `Title: ${r.title || ""}\nDescription: ${truncateResultContent(r.content || "", maxResultChars)}\nURL: ${r.url || ""}\nRelevance Score: ${score.toFixed(3)}`;
458
+ const lines = [
459
+ `Title: ${r.title || ""}`,
460
+ `Description: ${truncateResultContent(r.content || "", maxResultChars)}`,
461
+ `URL: ${r.url || ""}`,
462
+ ];
463
+ if (r.score !== undefined) {
464
+ lines.push(`Relevance Score: ${r.score.toFixed(3)}`);
465
+ }
466
+ return lines.join("\n");
370
467
  })
371
468
  .join("\n\n");
372
469
  return leadingSections ? `${leadingSections}\n\n---\n\n${formattedResults}` : formattedResults;
package/dist/types.d.ts CHANGED
@@ -3,7 +3,7 @@ export interface SearXNGWebResult {
3
3
  title: string;
4
4
  content: string;
5
5
  url: string;
6
- score: number;
6
+ score?: number;
7
7
  engine?: string;
8
8
  engines?: string[];
9
9
  category?: string;
@@ -23,6 +23,7 @@ export interface SearXNGWeb {
23
23
  query: string;
24
24
  number_of_results: number;
25
25
  results: SearXNGWebResult[];
26
+ sourceFormat?: "json" | "html";
26
27
  suggestions?: string[];
27
28
  corrections?: string[];
28
29
  answers?: string[];
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const packageVersion = "1.6.0";
1
+ export declare const packageVersion = "1.7.0";
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const packageVersion = "1.6.0";
1
+ export const packageVersion = "1.7.0";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-searxng",
3
- "version": "1.6.0",
3
+ "version": "1.7.0",
4
4
  "mcpName": "io.github.ihor-sokoliuk/mcp-searxng",
5
5
  "description": "MCP server for SearXNG integration",
6
6
  "license": "MIT",
@@ -55,16 +55,17 @@
55
55
  "express": "^5.2.1",
56
56
  "express-rate-limit": "^8.5.2",
57
57
  "node-html-markdown": "^2.0.0",
58
- "undici": "7.27.2"
58
+ "node-html-parser": "^6.1.13",
59
+ "undici": "7.28.0"
59
60
  },
60
61
  "devDependencies": {
61
- "@types/node": "22.19.20",
62
+ "@types/node": "22.19.21",
62
63
  "@types/supertest": "^7.2.0",
63
- "@typescript-eslint/eslint-plugin": "8.61.0",
64
- "@typescript-eslint/parser": "8.61.0",
64
+ "@typescript-eslint/eslint-plugin": "8.61.1",
65
+ "@typescript-eslint/parser": "8.61.1",
65
66
  "c8": "^11.0.0",
66
67
  "cross-env": "^10.1.0",
67
- "eslint": "10.4.1",
68
+ "eslint": "10.5.0",
68
69
  "eslint-plugin-security": "^4.0.0",
69
70
  "fast-check": "^4.8.0",
70
71
  "shx": "^0.4.0",