use async_trait::async_trait;
use std::time::Instant;
use crate::error::SearchError;
use crate::traits::{SearchEngine, SearchEngineInfo};
use crate::types::{SearchConfig, SearchItem, SearchOptions, SearchResult};
#[derive(Debug)]
pub struct SearxngEngine {
base_url: String,
client: reqwest::Client,
info: SearchEngineInfo,
}
impl SearxngEngine {
pub fn new(base_url: &str) -> Self {
Self {
base_url: base_url.trim_end_matches('/').to_string(),
client: reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(30))
.build()
.unwrap_or_default(),
info: SearchEngineInfo {
name: "searxng".into(),
display_name: "SearXNG 元搜索".into(),
description: "自托管元搜索引擎,聚合国内外多个搜索引擎".into(),
supported_sources: vec!["web".into(), "news".into(), "images".into()],
max_results: 20,
supported_regions: vec!["global".into(), "cn".into()],
supports_time_range: true,
pricing: None, },
}
}
pub fn base_url(&self) -> &str {
&self.base_url
}
fn build_url(&self, query: &str, config: &SearchConfig) -> String {
let mut url = format!("{}/search?format=json", self.base_url);
url.push_str(&format!("&q={}", urlencoding(query)));
url.push_str(&format!("&pageno={}", config.offset / config.max_results + 1));
if !config.engines.is_empty() {
url.push_str(&format!("&engines={}", config.engines.join(",")));
} else {
let has_news = config.sources.iter().any(|s| s == "news");
if has_news {
url.push_str("&categories=general,news");
} else {
url.push_str("&categories=general");
}
}
if let Some(ref lang) = config.language {
url.push_str(&format!("&language={}", lang));
}
if let Some(ref tr) = config.time_range {
url.push_str("&time_range=");
if tr.start.is_some() && tr.end.is_some() {
url.push_str(&format!(
"{}-{}",
tr.start.unwrap(),
tr.end.unwrap()
));
} else if let Some(start) = tr.start {
url.push_str(&start.to_string());
}
}
if let Some(ref safe) = config.safe_search {
let level = match safe {
crate::types::SafeSearchLevel::Off => 0,
crate::types::SafeSearchLevel::Moderate => 1,
crate::types::SafeSearchLevel::Strict => 2,
};
url.push_str(&format!("&safesearch={}", level));
}
url
}
fn parse_response(
&self,
raw: &serde_json::Value,
query: &str,
) -> Result<Vec<SearchItem>, SearchError> {
let results = raw["results"]
.as_array()
.ok_or_else(|| SearchError::Api {
engine: "searxng".into(),
message: "response missing 'results' array".into(),
})?;
let items: Vec<SearchItem> = results
.iter()
.map(|r| {
let url = r["url"].as_str().unwrap_or("").to_string();
SearchItem {
title: r["title"].as_str().unwrap_or("").to_string(),
url: url.clone(),
snippet: r["content"].as_str().unwrap_or("").to_string(),
source: r["engine"]
.as_str()
.unwrap_or("searxng")
.to_string(),
published_at: r["publishedDate"]
.as_str()
.and_then(|d| parse_iso8601_to_epoch(d)),
score: r["score"].as_f64().unwrap_or(0.5) as f32,
domain: extract_domain(&url),
detected_language: None,
extracted_content: None,
}
})
.collect();
Ok(items)
}
}
#[async_trait]
impl SearchEngine for SearxngEngine {
async fn search(
&self,
query: &str,
config: &SearchConfig,
_options: &SearchOptions,
) -> Result<SearchResult, SearchError> {
let start = Instant::now();
let url = self.build_url(query, config);
let response = self
.client
.get(&url)
.header("Accept", "application/json")
.header(
"User-Agent",
"XZ-Writer/1.0 (Novel Writing Assistant; +https://github.com/xiaozhu/xz-writer)",
)
.send()
.await
.map_err(|e| {
if e.is_timeout() {
SearchError::Timeout(30000)
} else {
SearchError::Network {
engine: "searxng".into(),
message: e.to_string(),
}
}
})?;
let status = response.status();
if !status.is_success() {
let body = response.text().await.unwrap_or_default();
return Err(SearchError::Api {
engine: "searxng".into(),
message: format!("HTTP {status}: {body}"),
});
}
let raw: serde_json::Value = response.json().await.map_err(|e| SearchError::Api {
engine: "searxng".into(),
message: format!("JSON parse error: {e}"),
})?;
if let Some(error_msg) = raw["error"].as_str() {
return Err(SearchError::Api {
engine: "searxng".into(),
message: error_msg.to_string(),
});
}
let mut items = self.parse_response(&raw, query)?;
let total = items.len() as u64;
items = items
.into_iter()
.skip(config.offset)
.take(config.max_results)
.collect();
Ok(SearchResult {
query: query.to_string(),
items,
total_results: raw["number_of_results"]
.as_u64()
.unwrap_or(total),
latency_ms: start.elapsed().as_millis() as u64,
cached: false,
engines_used: vec!["searxng".into()],
rewritten_query: None,
})
}
fn engine_info(&self) -> &SearchEngineInfo {
&self.info
}
}
fn extract_domain(url: &str) -> String {
url.trim_start_matches("https://")
.trim_start_matches("http://")
.split('/')
.next()
.unwrap_or(url)
.to_string()
}
fn urlencoding(s: &str) -> String {
percent_encoding::utf8_percent_encode(s, percent_encoding::NON_ALPHANUMERIC).to_string()
}
fn parse_iso8601_to_epoch(s: &str) -> Option<u64> {
let s = s.trim();
if s.len() < 10 {
return None;
}
let year: i32 = s[0..4].parse().ok()?;
let month: u32 = s[5..7].parse().ok()?;
let day: u32 = s[8..10].parse().ok()?;
if month < 1 || month > 12 || day < 1 || day > 31 {
return None;
}
let days_before_month = [0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334];
let leap = (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0);
let day_of_year = days_before_month[(month - 1) as usize] + day as i32
+ if leap && month > 2 { 1 } else { 0 };
let epoch_days = (year - 1970) as i64 * 365
+ ((year - 1969) / 4) as i64
- ((year - 1901) / 100) as i64
+ ((year - 1601) / 400) as i64
+ day_of_year as i64
- 1;
Some((epoch_days * 86400) as u64)
}