Skip to main content

web_search/providers/
bing.rs

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