Coverage for src/local_deep_research/web_search_engines/engines/search_engine_searxng.py: 95%
267 statements
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-06 15:42 +0000
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-06 15:42 +0000
1import enum
2import json
3from typing import Any, Dict, List, Optional
5import requests
6from langchain_core.language_models import BaseLLM
8from ...security import redact_url_for_log
9from ...security.safe_requests import safe_get
11from ..search_engine_base import BaseSearchEngine, Exposure, Sensitivity
12from ...security.secure_logging import logger
13from ._searxng_rate_limiter import respect_rate_limit
15# The operator-approval resolver lives in security/egress/validators.py next
16# to the allowlist matcher it consults (dependency direction: engine →
17# security, same as everything else here). Re-exported under the historical
18# private name so this module's call sites and the existing test imports
19# keep their import path (the signature itself gained the instance-URL
20# argument when the resolver was generalized).
21from ...security.egress.validators import (
22 resolve_searxng_allow_private_ips as _resolve_searxng_allow_private_ips,
23)
26@enum.unique
27class SafeSearchSetting(enum.IntEnum):
28 """
29 Acceptable settings for safe search.
30 """
32 OFF = 0
33 MODERATE = 1
34 STRICT = 2
37class SearXNGSearchEngine(BaseSearchEngine):
38 """
39 SearXNG search engine implementation that requires an instance URL provided via
40 environment variable or configuration. Designed for ethical usage with proper
41 rate limiting and single-instance approach.
42 """
44 # Mark as public search engine
45 is_public = True
46 egress_sensitivity = Sensitivity.NON_SENSITIVE
47 egress_exposure = Exposure.EXPOSING
48 # Mark as generic search engine (general web search)
49 is_generic = True
50 # The egress engine-selection gate uses the static is_public flag above —
51 # SearXNG always queries the internet regardless of where it's hosted, so
52 # a localhost instance_url does NOT reclassify it as private (the PDP's
53 # URL override is fail-up only and never relaxes a public nature).
54 url_setting = "search.engine.web.searxng.default_params.instance_url"
56 @staticmethod
57 def _normalize_list(value, *, setting_name: str = "list setting"):
58 """Ensure *value* is a ``list[str]`` or ``None``.
60 Settings saved via the web UI may arrive as raw JSON strings
61 (e.g. ``'[\\r\\n "general"\\r\\n]'``) instead of parsed lists.
62 This helper decodes such strings so that ``",".join()`` later
63 works on list items rather than individual characters (issue #1030).
65 Input that is neither valid JSON nor a plain comma-separated list
66 still produces a list, because the comma fallback splits whatever it
67 is given. That is right for ``kagi,brave`` and wrong for
68 ``['kagi', 'brave']``, which yields ``["['kagi'", "'brave']"]`` --
69 names no engine matches, so the filter silently selects nothing while
70 looking configured. Warn when the input was evidently meant to be
71 JSON, so the failure is visible rather than inferred from empty
72 results.
73 """
74 if value is None:
75 return None
76 if isinstance(value, list):
77 return value
78 if isinstance(value, str):
79 stripped = value.strip()
80 if stripped:
81 try:
82 parsed = json.loads(stripped)
83 if isinstance(parsed, list):
84 return [str(item) for item in parsed]
85 except (json.JSONDecodeError, ValueError, RecursionError):
86 # Only a bracketed/braced value was *trying* to be JSON.
87 # Genuine comma-separated input takes this path too and
88 # is not a mistake, so it must not warn.
89 if stripped[0] in "[{":
90 logger.warning(
91 "{} is not valid JSON ({!r}); falling back to "
92 "comma-splitting, which keeps brackets and quotes "
93 "as part of each entry. Use double-quoted JSON, "
94 'e.g. ["kagi", "brave"].',
95 setting_name,
96 stripped[:100],
97 )
98 # Comma-separated fallback
99 return [
100 item.strip() for item in stripped.split(",") if item.strip()
101 ]
102 return None
104 def _is_valid_search_result(self, url: str) -> bool:
105 """
106 Check if a parsed result is a valid search result vs an error page.
108 When SearXNG's backend engines fail or get rate-limited, it returns
109 error/stats pages that shouldn't be treated as search results.
111 Returns False for:
112 - Relative URLs (don't start with http:// or https://, case-insensitive)
113 - URLs pointing to the SearXNG instance itself (catches /stats, /preferences, etc.)
114 """
115 # Must have an absolute URL (case-insensitive scheme check)
116 if not url or not url.lower().startswith(("http://", "https://")):
117 return False
119 # Reject URLs pointing back to the SearXNG instance itself
120 # This catches all internal pages like /stats?engine=, /preferences, /about
121 if url.startswith(self.instance_url):
122 return False
124 return True
126 def _private_url_blocked_by_gate(self) -> bool:
127 """Whether the instance URL was refused *only* because it is
128 private/loopback/link-local with no operator approval active
129 (no allowlist match, no blanket opt-in, no env-locked URL).
131 Used to decide whether to surface the operator-remediation hint
132 (allowlist / blanket opt-in / env-lock). Detects
133 the case precisely so the hint fires for the common localhost
134 self-host misconfiguration but NOT for unrelated failures (DNS /
135 timeout / a genuinely-public URL) nor for an ALWAYS-blocked
136 cloud-metadata address — which the operator opt-in would never lift,
137 so pointing at that flag would be misleading.
139 Returns True when the URL is blocked under the current (gate-OFF)
140 posture yet WOULD pass with private egress allowed: that difference
141 is exactly the private-URL gate, and the opt-in is the fix. Fails
142 CLOSED (False) on any error so hint detection can never break init.
143 """
144 if self._allow_private_ips:
145 return False
146 try:
147 from ...security.ssrf_validator import validate_url
149 return not validate_url(
150 self.instance_url, allow_private_ips=False
151 ) and validate_url(self.instance_url, allow_private_ips=True)
152 except Exception: # noqa: silent-exception - hint detection is best-effort
153 return False
155 def __init__(
156 self,
157 max_results: int = 15,
158 instance_url: str = "http://localhost:8080",
159 categories: Optional[List[str]] = None,
160 engines: Optional[List[str]] = None,
161 language: str = "en",
162 safe_search: str = SafeSearchSetting.OFF.name,
163 time_range: Optional[str] = None,
164 result_format: str = "html",
165 delay_between_requests: float = 0.0,
166 llm: Optional[BaseLLM] = None,
167 max_filtered_results: Optional[int] = None,
168 include_full_content: bool = True,
169 settings_snapshot: Optional[Dict[str, Any]] = None,
170 **kwargs,
171 ): # API key is actually the instance URL
172 """
173 Initialize the SearXNG search engine with ethical usage patterns.
175 Args:
176 max_results: Maximum number of search results
177 instance_url: URL of your SearXNG instance (preferably self-hosted)
178 categories: List of SearXNG categories to search in (general, images, videos, news, etc.)
179 engines: List of engines to use (google, bing, duckduckgo, etc.)
180 language: Language code for search results
181 safe_search: Safe search level (0=off, 1=moderate, 2=strict)
182 time_range: Time range for results (day, week, month, year)
183 result_format: Response format to request from SearXNG, either
184 "html" (default, scraped) or "json". Use "json" for instances
185 whose ``search.formats`` enables JSON but not HTML.
186 delay_between_requests: Seconds to wait between requests
187 llm: Language model for relevance filtering
188 max_filtered_results: Maximum number of results to keep after filtering
189 include_full_content: Whether to include full webpage content in results
190 """
192 # Initialize the BaseSearchEngine with LLM, max_filtered_results, and max_results
193 super().__init__(
194 llm=llm,
195 max_filtered_results=max_filtered_results,
196 max_results=max_results,
197 include_full_content=include_full_content,
198 settings_snapshot=settings_snapshot,
199 **kwargs, # Pass through all other kwargs including search_snippets_only
200 )
202 # Validate and normalize the instance URL if provided
203 self.instance_url = instance_url.rstrip("/")
204 logger.info(
205 f"SearXNG initialized with instance URL: {redact_url_for_log(self.instance_url)}"
206 )
207 # Whether this instance's private/loopback URL may be reached. Derived
208 # from the operator opt-in (``LDR_SEARCH_ALLOW_PRIVATE_ENGINE_URLS``)
209 # or an env-locked instance URL only — NOT from the user-editable
210 # egress scope — rather than hard-coded True, so under the default
211 # PUBLIC_ONLY posture a user-editable instance_url pointed at an
212 # internal host is NOT fetched. Cloud-metadata IPs remain blocked
213 # either way. See ``_resolve_searxng_allow_private_ips`` for the rules.
214 self._allow_private_ips = _resolve_searxng_allow_private_ips(
215 self.instance_url
216 )
217 try:
218 # Make sure it's accessible.
219 response = safe_get(
220 self.instance_url,
221 timeout=5,
222 allow_private_ips=self._allow_private_ips,
223 )
224 if response.status_code == 200:
225 logger.info("SearXNG instance is accessible.")
226 self._is_available = True
227 else:
228 self._is_available = False
229 logger.error(
230 f"Failed to access SearXNG instance at {redact_url_for_log(self.instance_url)}. Status code: {response.status_code}"
231 )
232 except (requests.RequestException, ValueError) as e:
233 self._is_available = False
234 safe_msg = self._scrub_error(e)
235 logger.exception(
236 f"Error while trying to access SearXNG instance at {redact_url_for_log(self.instance_url)} ({type(e).__name__}): {safe_msg}"
237 )
238 private_url_hint = self._private_url_blocked_by_gate()
239 else:
240 private_url_hint = False
241 if private_url_hint:
242 # The dominant self-host case: default localhost:8080 SearXNG
243 # with no operator opt-in. The generic exception above never
244 # names the fix, so surface it explicitly on this runtime
245 # path — at ERROR, so the actionable message is at least as
246 # loud as the generic one preceding it. (Emitted outside the
247 # handler: there is no second traceback to attach, and the
248 # logger.exception-in-handler rule stays satisfied.)
249 logger.error(
250 "SearXNG engine disabled: instance URL "
251 f"{redact_url_for_log(self.instance_url)} is a private / "
252 "loopback / link-local address, which is not fetched by "
253 "default. To use a self-hosted SearXNG on localhost/LAN, "
254 "the server operator must approve it in the server "
255 "environment and restart: add the URL origin to "
256 "LDR_SEARCH_PRIVATE_ENGINE_URL_ALLOWLIST, or env-lock "
257 "the URL via "
258 "LDR_SEARCH_ENGINE_WEB_SEARXNG_DEFAULT_PARAMS_INSTANCE_URL "
259 "(as the bundled docker-compose does), or set "
260 "LDR_SEARCH_ALLOW_PRIVATE_ENGINE_URLS=true. Only one of "
261 "these is needed. Cloud-metadata addresses stay blocked "
262 "regardless. See docs/SearXNG-Setup.md."
263 )
265 # Add debug logging for all parameters
266 logger.info(
267 f"SearXNG init params: max_results={max_results}, language={language}, "
268 f"max_filtered_results={max_filtered_results}, is_available={self._is_available}"
269 )
271 self.max_results = max_results
272 self.categories = self._normalize_list(
273 categories, setting_name="SearXNG categories"
274 ) or ["general"]
275 self.engines = self._normalize_list(
276 engines, setting_name="SearXNG engines"
277 )
278 self.language = language
279 try:
280 # Handle both string names and integer values
281 if isinstance(safe_search, int) or (
282 isinstance(safe_search, str) and str(safe_search).isdigit()
283 ):
284 self.safe_search = SafeSearchSetting(int(safe_search))
285 else:
286 self.safe_search = SafeSearchSetting[safe_search]
287 except (ValueError, KeyError) as e:
288 safe_msg = self._scrub_error(e)
289 logger.exception(
290 f"'{safe_search}' is not a valid safe search setting ({type(e).__name__}). Disabling safe search: {safe_msg}"
291 )
292 self.safe_search = SafeSearchSetting.OFF
293 self.time_range = time_range
295 normalized_format = str(result_format).strip().lower()
296 if normalized_format not in ("html", "json"):
297 logger.warning(
298 f"'{result_format}' is not a supported SearXNG result_format "
299 "('html' or 'json'). Falling back to 'html'."
300 )
301 normalized_format = "html"
302 self.result_format = normalized_format
304 self.delay_between_requests = float(delay_between_requests)
306 if self._is_available:
307 self.search_url = f"{self.instance_url}/search"
308 logger.info(
309 f"SearXNG engine initialized with instance: {redact_url_for_log(self.instance_url)}"
310 )
311 logger.info(
312 f"Rate limiting set to {self.delay_between_requests} seconds between requests"
313 )
315 self._init_full_search(
316 web_search=self,
317 language=language,
318 max_results=max_results,
319 region="wt-wt",
320 time_period="y",
321 safe_search=self.safe_search.value,
322 )
324 def _respect_rate_limit(self):
325 """Apply self-imposed rate limiting between requests.
327 Rate-limit state is shared across all `SearXNGSearchEngine`
328 instances targeting the same ``instance_url`` (see
329 ``_searxng_rate_limiter.py``) so the configured delay applies
330 even when the research agent constructs a fresh engine for
331 every tool call.
332 """
333 respect_rate_limit(self.instance_url, self.delay_between_requests)
335 def _get_search_results(
336 self, query: str, max_results: Optional[int] = None
337 ) -> List[Dict[str, Any]]:
338 """
339 Get search results from SearXNG with ethical rate limiting.
341 Args:
342 query: The search query
343 max_results: Optional per-request result limit. Uses the engine's
344 configured limit when omitted.
346 Returns:
347 List of search results from SearXNG
348 """
349 raw_limit = self.max_results if max_results is None else max_results
350 result_limit = max(0, raw_limit)
352 if not self._is_available:
353 logger.error(
354 "SearXNG engine is disabled (no instance URL provided) - cannot run search"
355 )
356 return []
358 logger.info(f"SearXNG running search for query: {query}")
360 try:
361 self._respect_rate_limit()
363 initial_headers = {
364 "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
365 "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8",
366 "Accept-Language": "en-US,en;q=0.9",
367 }
369 try:
370 initial_response = safe_get(
371 self.instance_url,
372 headers=initial_headers,
373 timeout=10,
374 allow_private_ips=self._allow_private_ips,
375 )
376 cookies = initial_response.cookies
377 except Exception as e:
378 safe_msg = self._scrub_error(e)
379 logger.exception(
380 f"Failed to get initial cookies ({type(e).__name__}): {safe_msg}"
381 )
382 cookies = None
384 params = {
385 "q": query,
386 "categories": ",".join(self.categories),
387 "language": self.language,
388 "format": self.result_format,
389 "pageno": 1,
390 "safesearch": self.safe_search.value,
391 "count": result_limit,
392 }
394 if self.engines:
395 params["engines"] = ",".join(self.engines)
397 if self.time_range: 397 ↛ 398line 397 didn't jump to line 398 because the condition on line 397 was never true
398 params["time_range"] = self.time_range
400 # Browser-like headers
401 headers = {
402 "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
403 "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8",
404 "Accept-Language": "en-US,en;q=0.9",
405 "Referer": self.instance_url + "/",
406 "Connection": "keep-alive",
407 "Upgrade-Insecure-Requests": "1",
408 }
410 logger.info(
411 f"Sending request to SearXNG instance at {redact_url_for_log(self.instance_url)}"
412 )
413 response = safe_get(
414 self.search_url,
415 params=params,
416 headers=headers,
417 cookies=cookies,
418 timeout=15,
419 allow_private_ips=self._allow_private_ips,
420 )
422 if response.status_code == 200:
423 if self.result_format == "json":
424 return self._parse_json_results(
425 response, query, result_limit
426 )
428 try:
429 from bs4 import BeautifulSoup
431 soup = BeautifulSoup(response.text, "html.parser")
432 results: List[Dict[str, Any]] = []
434 result_elements = soup.select(".result-item")
436 if not result_elements:
437 result_elements = soup.select(".result")
439 if not result_elements:
440 result_elements = soup.select("article")
442 if not result_elements:
443 logger.debug(
444 f"Classes found in HTML: {[c['class'] for c in soup.select('[class]') if 'class' in c.attrs][:10]}"
445 )
446 result_elements = soup.select('div[id^="result"]')
448 logger.info(
449 f"Found {len(result_elements)} search result elements"
450 )
452 for idx, result_element in enumerate(result_elements):
453 if len(results) >= result_limit:
454 break
456 title_element = (
457 result_element.select_one(".result-title")
458 or result_element.select_one(".title")
459 or result_element.select_one("h3")
460 or result_element.select_one("a[href]")
461 )
463 url_element = (
464 result_element.select_one(".result-url")
465 or result_element.select_one(".url")
466 or result_element.select_one("a[href]")
467 )
469 content_element = (
470 result_element.select_one(".result-content")
471 or result_element.select_one(".content")
472 or result_element.select_one(".snippet")
473 or result_element.select_one("p")
474 )
476 title = (
477 title_element.get_text(" ", strip=True)
478 if title_element
479 else ""
480 )
482 url = ""
483 if url_element and url_element.has_attr("href"):
484 url = self._clean_result_url(url_element["href"])
485 elif url_element: 485 ↛ 488line 485 didn't jump to line 488 because the condition on line 485 was always true
486 url = url_element.get_text(strip=True)
488 content = (
489 content_element.get_text(" ", strip=True)
490 if content_element
491 else ""
492 )
494 if (
495 not url
496 and title_element
497 and title_element.has_attr("href")
498 ):
499 url = self._clean_result_url(title_element["href"])
501 logger.debug(
502 f"Extracted result {idx}: title={title[:30]}..., url={redact_url_for_log(url)}, content={content[:30]}..."
503 )
505 # Add to results only if it's a valid search result
506 # (not an error page or internal SearXNG page)
507 if self._is_valid_search_result(url):
508 results.append(
509 {
510 "title": title,
511 "url": url,
512 "content": content,
513 "engine": "searxng",
514 "category": "general",
515 }
516 )
517 else:
518 # Check if this is a backend engine failure
519 if url and "/stats?engine=" in url: 519 ↛ 529line 519 didn't jump to line 529 because the condition on line 519 was always true
520 try:
521 engine_name = url.split("/stats?engine=")[
522 1
523 ].split("&")[0]
524 logger.warning(
525 f"SearXNG backend engine failed or rate-limited: {engine_name}"
526 )
527 except (IndexError, AttributeError):
528 pass # Couldn't parse engine name
529 logger.debug(
530 f"Filtered invalid SearXNG result: title={title!r}, url={redact_url_for_log(url)}"
531 )
533 if results:
534 logger.info(
535 f"SearXNG returned {len(results)} valid results from HTML parsing"
536 )
537 else:
538 logger.warning(
539 f"SearXNG returned no valid results for query: {query}. "
540 "This may indicate SearXNG backend engine issues or rate limiting."
541 )
542 return results
544 except ImportError as e:
545 safe_msg = self._scrub_error(e)
546 logger.exception(
547 f"BeautifulSoup not available for HTML parsing ({type(e).__name__}): {safe_msg}"
548 )
549 return []
550 except Exception as e:
551 safe_msg = self._scrub_error(e)
552 logger.exception(
553 f"Error parsing HTML results ({type(e).__name__}): {safe_msg}"
554 )
555 return []
556 else:
557 logger.error(
558 f"SearXNG returned status code {response.status_code}"
559 )
560 return []
562 except Exception as e:
563 safe_msg = self._scrub_error(e)
564 logger.exception(
565 f"Error getting SearXNG results ({type(e).__name__}): {safe_msg}"
566 )
567 return []
569 def _parse_json_results(
570 self, response: Any, query: str, max_results: Optional[int] = None
571 ) -> List[Dict[str, Any]]:
572 """Parse SearXNG's ``format=json`` response.
574 SearXNG returns a JSON document whose ``results`` array already
575 carries structured ``url``/``title``/``content`` fields, so no HTML
576 scraping is needed. The output shape matches the HTML parse path
577 (``title``, ``url``, ``content``, ``engine``, ``category``) and the
578 same ``_is_valid_search_result`` filter is applied so both paths
579 reject SearXNG's own internal/stats pages identically.
581 Args:
582 response: HTTP response object containing JSON payload
583 query: The search query string
584 max_results: Optional per-request result limit. Uses the engine's
585 configured limit when omitted.
587 Returns:
588 List of search results from SearXNG
589 """
590 raw_limit = self.max_results if max_results is None else max_results
591 result_limit = max(0, raw_limit)
593 try:
594 data = response.json()
595 except (json.JSONDecodeError, ValueError) as e:
596 safe_msg = self._scrub_error(e)
597 logger.exception(
598 f"Error parsing JSON results ({type(e).__name__}): {safe_msg}. "
599 "Ensure the SearXNG instance enables the 'json' format."
600 )
601 return []
603 # Surface backend engine failures the same way the HTML path does.
604 unresponsive = data.get("unresponsive_engines") or []
605 if unresponsive: 605 ↛ 606line 605 didn't jump to line 606 because the condition on line 605 was never true
606 logger.warning(
607 f"SearXNG reported unresponsive engines: {unresponsive}"
608 )
610 results: List[Dict[str, Any]] = []
611 for item in data.get("results", []):
612 if len(results) >= result_limit:
613 break
614 if not isinstance(item, dict): 614 ↛ 615line 614 didn't jump to line 615 because the condition on line 614 was never true
615 continue
617 url = self._clean_result_url(item.get("url"))
618 if not self._is_valid_search_result(url):
619 logger.debug(
620 f"Filtered invalid SearXNG result: url={redact_url_for_log(url)}"
621 )
622 continue
624 results.append(
625 {
626 "title": item.get("title") or "",
627 "url": url,
628 "content": item.get("content") or "",
629 "engine": item.get("engine") or "searxng",
630 "category": item.get("category") or "general",
631 }
632 )
634 if results:
635 logger.info(
636 f"SearXNG returned {len(results)} valid results from JSON parsing"
637 )
638 else:
639 logger.warning(
640 f"SearXNG returned no valid results for query: {query}. "
641 "This may indicate SearXNG backend engine issues or rate limiting."
642 )
643 return results
645 def _get_previews(self, query: str) -> List[Dict[str, Any]]:
646 """
647 Get preview information for SearXNG search results.
649 Args:
650 query: The search query
652 Returns:
653 List of preview dictionaries
654 """
655 if not self._is_available:
656 logger.warning(
657 "SearXNG engine is disabled (no instance URL provided)"
658 )
659 return []
661 logger.info(f"Getting SearXNG previews for query: {query}")
663 results = self._get_search_results(query)
665 if not results:
666 logger.warning(f"No SearXNG results found for query: {query}")
667 return []
669 previews = []
670 for i, result in enumerate(results):
671 title = result.get("title", "")
672 url = self._clean_result_url(result.get("url"))
673 content = result.get("content", "")
675 preview = {
676 "id": url or f"searxng-result-{i}",
677 "title": title,
678 "link": url,
679 "snippet": content,
680 "engine": result.get("engine", ""),
681 "category": result.get("category", ""),
682 }
684 previews.append(preview)
686 return previews
688 def _get_full_content(
689 self, relevant_items: List[Dict[str, Any]]
690 ) -> List[Dict[str, Any]]:
691 """
692 Get full content for the relevant search results.
694 Args:
695 relevant_items: List of relevant preview dictionaries
697 Returns:
698 List of result dictionaries with full content
699 """
700 if not self._is_available:
701 return relevant_items
703 if not hasattr(self, "full_search"): 703 ↛ 704line 703 didn't jump to line 704 because the condition on line 703 was never true
704 return relevant_items
706 logger.info("Retrieving full webpage content")
708 try:
709 return self.full_search._get_full_content(relevant_items)
711 except Exception as e:
712 safe_msg = self._scrub_error(e)
713 logger.exception(
714 f"Error retrieving full content ({type(e).__name__}): {safe_msg}"
715 )
716 return relevant_items
718 def invoke(self, query: str) -> List[Dict[str, Any]]:
719 """Compatibility method for LangChain tools"""
720 return self.run(query)
722 def results(
723 self, query: str, max_results: Optional[int] = None
724 ) -> List[Dict[str, Any]]:
725 """
726 Get search results in a format compatible with other search engines.
728 Args:
729 query: The search query
730 max_results: Optional override for maximum results
732 Returns:
733 List of search result dictionaries
734 """
735 if not self._is_available:
736 return []
738 results = self._get_search_results(query, max_results=max_results)
740 formatted_results = []
741 for result in results:
742 formatted_results.append(
743 {
744 "title": result.get("title", ""),
745 "link": self._clean_result_url(result.get("url")),
746 "snippet": result.get("content", ""),
747 }
748 )
750 return formatted_results
752 @staticmethod
753 def get_self_hosting_instructions() -> str:
754 """
755 Get instructions for self-hosting a SearXNG instance.
757 Returns:
758 String with installation instructions
759 """
760 return """
761# SearXNG Self-Hosting Instructions
763The most ethical way to use SearXNG is to host your own instance. Here's how:
765## Using Docker (easiest method)
7671. Install Docker if you don't have it already
7682. Run these commands:
770```bash
771# Pull the SearXNG Docker image
772docker pull searxng/searxng
774# Run SearXNG (will be available at http://localhost:8080)
775docker run -d -p 8080:8080 --name searxng searxng/searxng
776```
778## Using Docker Compose (recommended for production)
7801. Create a file named `docker-compose.yml` with the following content:
782```yaml
783version: '3'
784services:
785 searxng:
786 container_name: searxng
787 image: searxng/searxng
788 ports:
789 - "8080:8080"
790 volumes:
791 - ./searxng:/etc/searxng
792 environment:
793 - SEARXNG_BASE_URL=http://localhost:8080/
794 restart: unless-stopped
795```
7972. Run with Docker Compose:
799```bash
800docker-compose up -d
801```
803For more detailed instructions and configuration options, visit:
804https://searxng.github.io/searxng/admin/installation.html
805"""
807 def run(
808 self, query: str, research_context: Dict[str, Any] | None = None
809 ) -> List[Dict[str, Any]]:
810 """
811 Override BaseSearchEngine run method to add SearXNG-specific error handling.
812 """
813 if not self._is_available:
814 logger.error(
815 "SearXNG run method called but engine is not available (missing instance URL)"
816 )
817 return []
819 logger.info(
820 f"SearXNG instance URL: {redact_url_for_log(self.instance_url)}"
821 )
823 try:
824 # Call the parent class's run method
825 results = super().run(query, research_context=research_context)
826 logger.info(f"SearXNG search completed with {len(results)} results")
827 return results
828 except Exception as e:
829 safe_msg = self._scrub_error(e)
830 logger.exception(
831 f"Error in SearXNG run method ({type(e).__name__}): {safe_msg}"
832 )
833 # Return empty results on error
834 return []