feat: add SearXNG search provider with custom URL and JSON output

This commit is contained in:
beann
2026-03-30 18:04:39 +08:00
parent 64631a31e6
commit 656e2734ce
2 changed files with 136 additions and 2 deletions
+104 -1
View File
@@ -55,6 +55,7 @@ impl WebSearchEngine {
SearchProvider::Tavily => self.search_tavily(query, max_results).await,
SearchProvider::Perplexity => self.search_perplexity(query).await,
SearchProvider::DuckDuckGo => self.search_duckduckgo(query, max_results).await,
SearchProvider::Searxng => self.search_searxng(query, max_results).await,
SearchProvider::Auto => self.search_auto(query, max_results).await,
};
@@ -67,7 +68,7 @@ impl WebSearchEngine {
}
/// Auto-select provider based on available API keys.
/// Priority: Tavily → Brave → Perplexity → DuckDuckGo
/// Priority: Tavily → Brave → Perplexity → Searxng → DuckDuckGo
async fn search_auto(&self, query: &str, max_results: usize) -> Result<String, String> {
// Tavily first (AI-agent-native)
if resolve_api_key(&self.config.tavily.api_key_env).is_some() {
@@ -96,6 +97,15 @@ impl WebSearchEngine {
}
}
// Searxng fourth (self-hosted, no API key needed)
if !self.config.searxng.url.is_empty() {
debug!("Auto: trying Searxng");
match self.search_searxng(query, max_results).await {
Ok(result) => return Ok(result),
Err(e) => warn!("Searxng failed, falling back: {e}"),
}
}
// DuckDuckGo always available as zero-config fallback
debug!("Auto: falling back to DuckDuckGo");
self.search_duckduckgo(query, max_results).await
@@ -313,6 +323,99 @@ impl WebSearchEngine {
Ok(output)
}
/// Search via SearXNG self-hosted instance.
async fn search_searxng(&self, query: &str, max_results: usize) -> Result<String, String> {
if self.config.searxng.url.is_empty() {
return Err("SearXNG URL is not configured".to_string());
}
let limit = max_results.min(self.config.searxng.max_results);
debug!(query, "Searching via SearXNG");
let resp = self
.client
.get(format!("{}/search", self.config.searxng.url.trim_end_matches('/')))
.query(&[
("q", query),
("format", "json"),
("engines", "general"),
("limit", &limit.to_string()),
])
.header("User-Agent", "Mozilla/5.0 (compatible; OpenFangAgent/0.1)")
.send()
.await
.map_err(|e| format!("SearXNG request failed: {e}"))?;
if !resp.status().is_success() {
return Err(format!("SearXNG API returned {}", resp.status()));
}
#[derive(serde::Deserialize)]
struct SearxngResponse {
results: Vec<SearxngResult>,
query: String,
}
#[derive(serde::Deserialize)]
struct SearxngResult {
url: String,
title: String,
content: Option<String>,
}
let data: SearxngResponse = resp
.json()
.await
.map_err(|e| format!("SearXNG JSON parse failed: {e}"))?;
if data.results.is_empty() {
return Err(format!("No results found for '{query}' (SearXNG)."));
}
#[derive(serde::Serialize)]
struct OutputResult<'a> {
title: &'a str,
url: &'a str,
content: &'a str,
}
#[derive(serde::Serialize)]
struct Output<'a> {
query: &'a str,
results: Vec<OutputResult<'a>>,
}
let results: Vec<OutputResult> = data
.results
.iter()
.map(|r| OutputResult {
title: &r.title,
url: &r.url,
content: r.content.as_deref().unwrap_or(""),
})
.collect();
let output = Output {
query: &data.query,
results,
};
serde_json::to_string(&output)
.map_err(|e| format!("Failed to serialize SearXNG results: {e}"))
}
/// Return all non-Auto search providers (for provider listing/discovery).
pub fn all_providers() -> Vec<SearchProvider> {
vec![
SearchProvider::Brave,
SearchProvider::Tavily,
SearchProvider::Perplexity,
SearchProvider::DuckDuckGo,
SearchProvider::Searxng,
]
}
}
// ---------------------------------------------------------------------------
+32 -1
View File
@@ -173,7 +173,9 @@ pub enum SearchProvider {
Perplexity,
/// DuckDuckGo HTML (no API key needed).
DuckDuckGo,
/// Auto-select based on available API keys (Tavily → Brave → Perplexity → DuckDuckGo).
/// SearXNG self-hosted search (no API key needed).
Searxng,
/// Auto-select based on available API keys (Tavily → Brave → Perplexity → Searxng → DuckDuckGo).
#[default]
Auto,
}
@@ -192,6 +194,8 @@ pub struct WebConfig {
pub tavily: TavilySearchConfig,
/// Perplexity Search configuration.
pub perplexity: PerplexitySearchConfig,
/// SearXNG Search configuration.
pub searxng: SearxngSearchConfig,
/// Web fetch configuration.
pub fetch: WebFetchConfig,
}
@@ -204,6 +208,7 @@ impl Default for WebConfig {
brave: BraveSearchConfig::default(),
tavily: TavilySearchConfig::default(),
perplexity: PerplexitySearchConfig::default(),
searxng: SearxngSearchConfig::default(),
fetch: WebFetchConfig::default(),
}
}
@@ -281,6 +286,25 @@ impl Default for PerplexitySearchConfig {
}
}
/// SearXNG self-hosted search configuration.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct SearxngSearchConfig {
/// Base URL of the SearXNG instance (e.g., "https://search.example.com").
pub url: String,
/// Maximum results to return.
pub max_results: usize,
}
impl Default for SearxngSearchConfig {
fn default() -> Self {
Self {
url: String::new(),
max_results: 5,
}
}
}
/// Web fetch configuration.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
@@ -3588,6 +3612,13 @@ impl KernelConfig {
));
}
}
SearchProvider::Searxng => {
if self.web.searxng.url.is_empty() {
warnings.push(
"Searxng search selected but searxng.url is not configured".to_string(),
);
}
}
SearchProvider::DuckDuckGo | SearchProvider::Auto => {}
}