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

1import enum 

2import json 

3from typing import Any, Dict, List, Optional 

4 

5import requests 

6from langchain_core.language_models import BaseLLM 

7 

8from ...security import redact_url_for_log 

9from ...security.safe_requests import safe_get 

10 

11from ..search_engine_base import BaseSearchEngine, Exposure, Sensitivity 

12from ...security.secure_logging import logger 

13from ._searxng_rate_limiter import respect_rate_limit 

14 

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) 

24 

25 

26@enum.unique 

27class SafeSearchSetting(enum.IntEnum): 

28 """ 

29 Acceptable settings for safe search. 

30 """ 

31 

32 OFF = 0 

33 MODERATE = 1 

34 STRICT = 2 

35 

36 

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 """ 

43 

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" 

55 

56 @staticmethod 

57 def _normalize_list(value, *, setting_name: str = "list setting"): 

58 """Ensure *value* is a ``list[str]`` or ``None``. 

59 

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). 

64 

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 

103 

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. 

107 

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. 

110 

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 

118 

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 

123 

124 return True 

125 

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). 

130 

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. 

138 

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 

148 

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 

154 

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. 

174 

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 """ 

191 

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 ) 

201 

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 ) 

264 

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 ) 

270 

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 

294 

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 

303 

304 self.delay_between_requests = float(delay_between_requests) 

305 

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 ) 

314 

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 ) 

323 

324 def _respect_rate_limit(self): 

325 """Apply self-imposed rate limiting between requests. 

326 

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) 

334 

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. 

340 

341 Args: 

342 query: The search query 

343 max_results: Optional per-request result limit. Uses the engine's 

344 configured limit when omitted. 

345 

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) 

351 

352 if not self._is_available: 

353 logger.error( 

354 "SearXNG engine is disabled (no instance URL provided) - cannot run search" 

355 ) 

356 return [] 

357 

358 logger.info(f"SearXNG running search for query: {query}") 

359 

360 try: 

361 self._respect_rate_limit() 

362 

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 } 

368 

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 

383 

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 } 

393 

394 if self.engines: 

395 params["engines"] = ",".join(self.engines) 

396 

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 

399 

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 } 

409 

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 ) 

421 

422 if response.status_code == 200: 

423 if self.result_format == "json": 

424 return self._parse_json_results( 

425 response, query, result_limit 

426 ) 

427 

428 try: 

429 from bs4 import BeautifulSoup 

430 

431 soup = BeautifulSoup(response.text, "html.parser") 

432 results: List[Dict[str, Any]] = [] 

433 

434 result_elements = soup.select(".result-item") 

435 

436 if not result_elements: 

437 result_elements = soup.select(".result") 

438 

439 if not result_elements: 

440 result_elements = soup.select("article") 

441 

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"]') 

447 

448 logger.info( 

449 f"Found {len(result_elements)} search result elements" 

450 ) 

451 

452 for idx, result_element in enumerate(result_elements): 

453 if len(results) >= result_limit: 

454 break 

455 

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 ) 

462 

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 ) 

468 

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 ) 

475 

476 title = ( 

477 title_element.get_text(" ", strip=True) 

478 if title_element 

479 else "" 

480 ) 

481 

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) 

487 

488 content = ( 

489 content_element.get_text(" ", strip=True) 

490 if content_element 

491 else "" 

492 ) 

493 

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"]) 

500 

501 logger.debug( 

502 f"Extracted result {idx}: title={title[:30]}..., url={redact_url_for_log(url)}, content={content[:30]}..." 

503 ) 

504 

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 ) 

532 

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 

543 

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 [] 

561 

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 [] 

568 

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. 

573 

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. 

580 

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. 

586 

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) 

592 

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 [] 

602 

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 ) 

609 

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 

616 

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 

623 

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 ) 

633 

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 

644 

645 def _get_previews(self, query: str) -> List[Dict[str, Any]]: 

646 """ 

647 Get preview information for SearXNG search results. 

648 

649 Args: 

650 query: The search query 

651 

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 [] 

660 

661 logger.info(f"Getting SearXNG previews for query: {query}") 

662 

663 results = self._get_search_results(query) 

664 

665 if not results: 

666 logger.warning(f"No SearXNG results found for query: {query}") 

667 return [] 

668 

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", "") 

674 

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 } 

683 

684 previews.append(preview) 

685 

686 return previews 

687 

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. 

693 

694 Args: 

695 relevant_items: List of relevant preview dictionaries 

696 

697 Returns: 

698 List of result dictionaries with full content 

699 """ 

700 if not self._is_available: 

701 return relevant_items 

702 

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 

705 

706 logger.info("Retrieving full webpage content") 

707 

708 try: 

709 return self.full_search._get_full_content(relevant_items) 

710 

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 

717 

718 def invoke(self, query: str) -> List[Dict[str, Any]]: 

719 """Compatibility method for LangChain tools""" 

720 return self.run(query) 

721 

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. 

727 

728 Args: 

729 query: The search query 

730 max_results: Optional override for maximum results 

731 

732 Returns: 

733 List of search result dictionaries 

734 """ 

735 if not self._is_available: 

736 return [] 

737 

738 results = self._get_search_results(query, max_results=max_results) 

739 

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 ) 

749 

750 return formatted_results 

751 

752 @staticmethod 

753 def get_self_hosting_instructions() -> str: 

754 """ 

755 Get instructions for self-hosting a SearXNG instance. 

756 

757 Returns: 

758 String with installation instructions 

759 """ 

760 return """ 

761# SearXNG Self-Hosting Instructions 

762 

763The most ethical way to use SearXNG is to host your own instance. Here's how: 

764 

765## Using Docker (easiest method) 

766 

7671. Install Docker if you don't have it already 

7682. Run these commands: 

769 

770```bash 

771# Pull the SearXNG Docker image 

772docker pull searxng/searxng 

773 

774# Run SearXNG (will be available at http://localhost:8080) 

775docker run -d -p 8080:8080 --name searxng searxng/searxng 

776``` 

777 

778## Using Docker Compose (recommended for production) 

779 

7801. Create a file named `docker-compose.yml` with the following content: 

781 

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``` 

796 

7972. Run with Docker Compose: 

798 

799```bash 

800docker-compose up -d 

801``` 

802 

803For more detailed instructions and configuration options, visit: 

804https://searxng.github.io/searxng/admin/installation.html 

805""" 

806 

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 [] 

818 

819 logger.info( 

820 f"SearXNG instance URL: {redact_url_for_log(self.instance_url)}" 

821 ) 

822 

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 []