Skip to main content

web_search/providers/
generic.rs

1//! Generic, descriptor-driven search provider.
2//!
3//! A single provider implementation that can speak to any engine described by a
4//! catalog [`EngineDescriptor`](super::engines::EngineDescriptor). This keeps
5//! the fetch/normalize/error plumbing in one place while each engine only
6//! declares its URL, request kind, and parser. Mirrors the JavaScript
7//! `src/providers/generic.js` (issue #3 parity requirement).
8
9use async_trait::async_trait;
10use std::collections::BTreeMap;
11
12use super::base::{SearchOptions, SearchProvider, SearchResult};
13use super::engines::{EngineDescriptor, EngineKind, HttpMethod};
14use crate::error::SearchError;
15use crate::transport::{ReqwestTransport, SearchTransport, TransportRequest};
16
17const USER_AGENT: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 \
18                          (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
19
20/// Descriptor-driven provider.
21pub struct GenericProvider {
22    descriptor: EngineDescriptor,
23    enabled: bool,
24    weight: f64,
25}
26
27impl GenericProvider {
28    /// Create a new provider for the given engine descriptor.
29    pub fn new(descriptor: EngineDescriptor) -> Self {
30        Self {
31            descriptor,
32            enabled: true,
33            weight: 1.0,
34        }
35    }
36
37    /// The engine descriptor backing this provider.
38    pub fn descriptor(&self) -> &EngineDescriptor {
39        &self.descriptor
40    }
41
42    fn build_headers(&self, options: &SearchOptions) -> BTreeMap<String, String> {
43        let mut headers = BTreeMap::new();
44        headers.insert("User-Agent".to_string(), USER_AGENT.to_string());
45        headers.insert("Accept-Language".to_string(), "en-US,en;q=0.9".to_string());
46        let accept = match self.descriptor.kind {
47            EngineKind::Json => "application/json",
48            EngineKind::Text | EngineKind::Html => {
49                "text/html,application/xhtml+xml,application/xml;q=0.9"
50            }
51        };
52        headers.insert("Accept".to_string(), accept.to_string());
53
54        if let Some(extra) = self.descriptor.headers {
55            for (name, value) in extra(options) {
56                headers.insert(name, value);
57            }
58        }
59        headers
60    }
61}
62
63#[async_trait]
64impl SearchProvider for GenericProvider {
65    fn name(&self) -> &str {
66        self.descriptor.id
67    }
68
69    fn is_available(&self) -> bool {
70        self.enabled
71    }
72
73    fn weight(&self) -> f64 {
74        self.weight
75    }
76
77    fn set_weight(&mut self, weight: f64) {
78        self.weight = weight.clamp(0.0, 1.0);
79    }
80
81    fn set_enabled(&mut self, enabled: bool) {
82        self.enabled = enabled;
83    }
84
85    async fn search(
86        &self,
87        query: &str,
88        options: &SearchOptions,
89    ) -> Result<Vec<SearchResult>, SearchError> {
90        match self
91            .search_with_transport(query, options, &ReqwestTransport::default())
92            .await
93        {
94            Ok(results) => Ok(results),
95            Err(error) => {
96                tracing::error!("{} search error: {}", self.name(), error);
97                Ok(Vec::new())
98            }
99        }
100    }
101
102    async fn search_with_transport(
103        &self,
104        query: &str,
105        options: &SearchOptions,
106        transport: &dyn SearchTransport,
107    ) -> Result<Vec<SearchResult>, SearchError> {
108        if query.trim().is_empty() {
109            return Ok(Vec::new());
110        }
111
112        let d = &self.descriptor;
113        let limit = options.limit.unwrap_or(10);
114        let url = (d.build_url)(query, options);
115        let mut headers = self.build_headers(options);
116
117        let method = match d.method {
118            HttpMethod::Get => "GET",
119            HttpMethod::Post => "POST",
120        };
121        let mut body = None;
122        if let (HttpMethod::Post, Some(build_body)) = (d.method, d.build_body) {
123            headers.insert(
124                "Content-Type".to_string(),
125                "application/x-www-form-urlencoded".to_string(),
126            );
127            body = Some((build_body)(query, options).into_bytes());
128        }
129        let response = transport
130            .execute(TransportRequest {
131                method: method.to_string(),
132                url,
133                headers,
134                body,
135            })
136            .await?;
137
138        if !(200..300).contains(&response.status) {
139            return Err(SearchError::ApiError {
140                provider: d.id.to_string(),
141                message: format!("HTTP {}", response.status),
142            });
143        }
144        let body = String::from_utf8_lossy(&response.body);
145        Ok((d.parse)(&body, limit, options))
146    }
147}