Skip to main content

web_search/providers/
bing.rs

1//! Bing search provider
2
3use async_trait::async_trait;
4use serde::Deserialize;
5
6use super::base::{SearchOptions, SearchProvider, SearchResult};
7use crate::error::SearchError;
8
9/// Bing Web Search API response
10#[derive(Debug, Deserialize)]
11#[serde(rename_all = "camelCase")]
12struct BingApiResponse {
13    web_pages: Option<BingWebPages>,
14}
15
16#[derive(Debug, Deserialize)]
17struct BingWebPages {
18    value: Vec<BingWebPage>,
19}
20
21#[derive(Debug, Deserialize)]
22struct BingWebPage {
23    name: String,
24    url: String,
25    snippet: Option<String>,
26}
27
28/// Configuration for Bing provider
29#[derive(Debug, Clone, Default)]
30pub struct BingConfig {
31    /// Bing Search API key
32    pub api_key: Option<String>,
33}
34
35/// Bing search provider
36pub struct BingProvider {
37    name: String,
38    enabled: bool,
39    weight: f64,
40    client: reqwest::Client,
41    config: BingConfig,
42    api_url: String,
43}
44
45impl BingProvider {
46    /// Create a new Bing provider with optional API credentials
47    pub fn new(config: BingConfig) -> Self {
48        Self {
49            name: "bing".to_string(),
50            enabled: true,
51            weight: 1.0,
52            client: reqwest::Client::builder()
53                .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")
54                .build()
55                .expect("Failed to create HTTP client"),
56            config,
57            api_url: "https://api.bing.microsoft.com/v7.0/search".to_string(),
58        }
59    }
60
61    /// Create a new Bing provider from environment variables
62    pub fn from_env() -> Self {
63        Self::new(BingConfig {
64            api_key: std::env::var("BING_API_KEY").ok(),
65        })
66    }
67
68    /// Check if API credentials are configured
69    pub fn has_api_credentials(&self) -> bool {
70        self.config.api_key.is_some()
71    }
72
73    async fn search_with_api(
74        &self,
75        query: &str,
76        options: &SearchOptions,
77    ) -> Result<Vec<SearchResult>, SearchError> {
78        let api_key = self.config.api_key.as_ref().unwrap();
79        let limit = options.limit.unwrap_or(10).min(50);
80
81        let mut url = format!(
82            "{}?q={}&count={}&responseFilter=Webpages",
83            self.api_url,
84            urlencoding::encode(query),
85            limit
86        );
87
88        if let Some(ref region) = options.region {
89            let lang = options.language.as_deref().unwrap_or("en");
90            url.push_str(&format!("&mkt={}-{}", lang, region.to_uppercase()));
91        }
92
93        let safe_search = match options.safe_search {
94            Some(true) => "Strict",
95            Some(false) => "Off",
96            None => "Moderate",
97        };
98        url.push_str(&format!("&safeSearch={}", safe_search));
99
100        let response = self
101            .client
102            .get(&url)
103            .header("Ocp-Apim-Subscription-Key", api_key)
104            .send()
105            .await?;
106
107        if !response.status().is_success() {
108            let error_text = response.text().await.unwrap_or_default();
109            return Err(SearchError::ApiError {
110                provider: self.name.clone(),
111                message: error_text,
112            });
113        }
114
115        let api_response: BingApiResponse = response.json().await?;
116
117        let results = api_response
118            .web_pages
119            .map(|wp| wp.value)
120            .unwrap_or_default()
121            .into_iter()
122            .enumerate()
123            .map(|(i, page)| SearchResult {
124                title: page.name,
125                url: page.url,
126                snippet: page.snippet.unwrap_or_default(),
127                source: self.name.clone(),
128                rank: i + 1,
129                score: None,
130                sources: None,
131            })
132            .collect();
133
134        Ok(results)
135    }
136
137    async fn search_with_scraping(
138        &self,
139        query: &str,
140        options: &SearchOptions,
141    ) -> Result<Vec<SearchResult>, SearchError> {
142        let limit = options.limit.unwrap_or(10);
143        let mut url = format!(
144            "https://www.bing.com/search?q={}&count={}",
145            urlencoding::encode(query),
146            limit.min(30)
147        );
148
149        if let Some(ref region) = options.region {
150            url.push_str(&format!("&cc={}", region.to_uppercase()));
151        }
152
153        let response = self
154            .client
155            .get(&url)
156            .header(
157                "Accept",
158                "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
159            )
160            .header("Accept-Language", "en-US,en;q=0.5")
161            .send()
162            .await?;
163
164        if !response.status().is_success() {
165            return Err(SearchError::ApiError {
166                provider: self.name.clone(),
167                message: format!("HTTP {}", response.status()),
168            });
169        }
170
171        let html = response.text().await?;
172        Ok(self.parse_scraped_results(&html, limit))
173    }
174
175    fn parse_scraped_results(&self, html: &str, limit: usize) -> Vec<SearchResult> {
176        use scraper::{Html, Selector};
177
178        let document = Html::parse_document(html);
179        let mut results = Vec::new();
180
181        let algo_selector = Selector::parse(".b_algo").unwrap();
182        let link_selector = Selector::parse("a").unwrap();
183        let h2_selector = Selector::parse("h2").unwrap();
184        let snippet_selector = Selector::parse("p").unwrap();
185
186        for element in document.select(&algo_selector) {
187            if results.len() >= limit {
188                break;
189            }
190
191            let link = element.select(&link_selector).next();
192            let h2 = element.select(&h2_selector).next();
193            let snippet = element.select(&snippet_selector).next();
194
195            if let Some(link_elem) = link {
196                let url = link_elem.value().attr("href").unwrap_or_default();
197                if url.is_empty() || url.contains("bing.com") || url.starts_with('/') {
198                    continue;
199                }
200
201                let title = h2
202                    .map(|h| h.text().collect::<String>())
203                    .unwrap_or_else(|| link_elem.text().collect::<String>())
204                    .trim()
205                    .to_string();
206
207                let snippet_text = snippet
208                    .map(|s| s.text().collect::<String>())
209                    .unwrap_or_default()
210                    .trim()
211                    .to_string();
212
213                if title.is_empty() {
214                    continue;
215                }
216
217                results.push(SearchResult {
218                    title,
219                    url: url.to_string(),
220                    snippet: snippet_text,
221                    source: self.name.clone(),
222                    rank: results.len() + 1,
223                    score: None,
224                    sources: None,
225                });
226            }
227        }
228
229        results
230    }
231}
232
233impl Default for BingProvider {
234    fn default() -> Self {
235        Self::from_env()
236    }
237}
238
239#[async_trait]
240impl SearchProvider for BingProvider {
241    fn name(&self) -> &str {
242        &self.name
243    }
244
245    fn is_available(&self) -> bool {
246        self.enabled
247    }
248
249    fn weight(&self) -> f64 {
250        self.weight
251    }
252
253    fn set_weight(&mut self, weight: f64) {
254        self.weight = weight.clamp(0.0, 1.0);
255    }
256
257    fn set_enabled(&mut self, enabled: bool) {
258        self.enabled = enabled;
259    }
260
261    async fn search(
262        &self,
263        query: &str,
264        options: &SearchOptions,
265    ) -> Result<Vec<SearchResult>, SearchError> {
266        if query.is_empty() {
267            return Ok(Vec::new());
268        }
269
270        if self.has_api_credentials() {
271            match self.search_with_api(query, options).await {
272                Ok(results) => return Ok(results),
273                Err(e) => {
274                    tracing::warn!("Bing API search failed, falling back to scraping: {}", e);
275                }
276            }
277        }
278
279        self.search_with_scraping(query, options).await
280    }
281}