xz-search 0.1.2

外部搜索抽象层 — 多引擎聚合路由 + 内容提取
Documentation
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};

/// SearXNG 搜索引擎适配
///
/// SearXNG 是自托管的元搜索引擎,聚合 Google/Bing/DuckDuckGo 等多个搜索引擎的结果。
/// 支持国内搜索引擎(百度、必应等),无需 API Key,通过 HTTP GET 请求即可使用。
///
/// API 格式: `GET {base_url}/search?format=json&q={query}&categories=general&language=zh-CN`
#[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");
            }
        }

        // Language
        if let Some(ref lang) = config.language {
            url.push_str(&format!("&language={}", lang));
        }

        // Time range
        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());
            }
        }

        // Safe search
        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}"),
        })?;

        // Check for SearXNG error response
        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;

        // Apply max_results limit and offset
        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)
}