1use 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#[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#[derive(Debug, Clone, Default)]
35pub struct BingConfig {
36 pub api_key: Option<String>,
38}
39
40pub struct BingProvider {
42 name: String,
43 enabled: bool,
44 weight: f64,
45 config: BingConfig,
46 api_url: String,
47}
48
49impl BingProvider {
50 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 pub fn from_env() -> Self {
63 Self::new(BingConfig {
64 api_key: std::env::var("BING_API_KEY").ok(),
65 })
66 }
67
68 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}