use super::engine::{decode_html_entities, url_encode, SearchEngine, SearchError, SearchResult};
use super::ddg::DuckDuckGoEngine;
pub struct BingEngine {
client: reqwest::Client,
ddg: DuckDuckGoEngine,
}
impl BingEngine {
pub fn new(client: reqwest::Client) -> Self {
let ddg_client = client.clone();
Self {
client,
ddg: DuckDuckGoEngine::new(ddg_client),
}
}
}
#[async_trait::async_trait]
impl SearchEngine for BingEngine {
fn name(&self) -> &'static str {
"Bing"
}
async fn search(&self, query: &str, max_results: usize) -> Result<Vec<SearchResult>, SearchError> {
match self.search_bing(query, max_results).await {
Ok(results) => Ok(results),
Err(SearchError::Captcha(msg)) => {
tracing::warn!("{msg}, falling back to DuckDuckGo");
self.ddg.search(query, max_results).await
}
Err(e) => Err(e),
}
}
}
impl BingEngine {
async fn search_bing(&self, query: &str, max_results: usize) -> Result<Vec<SearchResult>, SearchError> {
let encoded = url_encode(query);
let url = format!("https://www.bing.com/search?q={encoded}");
let response = self.client
.get(&url)
.header("Accept-Language", "en-US,en;q=0.5")
.send()
.await
.map_err(|e| SearchError::Network(e.to_string()))?;
let status = response.status();
if !status.is_success() {
return Err(SearchError::Network(format!("Bing returned HTTP {status}")));
}
let html = response
.text()
.await
.map_err(|e| SearchError::Network(e.to_string()))?;
if html.contains("CaptchaChallenge") || html.contains(" Bing captcha ") {
return Err(SearchError::Captcha("Bing CAPTCHA challenge detected".into()));
}
Ok(parse_bing_html(&html, max_results))
}
}
fn parse_bing_html(html: &str, max_results: usize) -> Vec<SearchResult> {
let mut results = Vec::new();
for block in html.split(r##"class="b_algo""##).skip(1) {
if results.len() >= max_results {
break;
}
let (title, url) = extract_bing_link(block);
let snippet = extract_bing_snippet(block);
if url.is_empty() || title.is_empty() {
continue;
}
results.push(SearchResult {
title: decode_html_entities(&title),
url,
snippet: decode_html_entities(&snippet),
source: "Bing".into(),
extra: None,
});
}
results
}
fn extract_bing_link(block: &str) -> (String, String) {
let h2_start = find_tag_open(block, "h2").unwrap_or(usize::MAX);
if h2_start == usize::MAX {
return (String::new(), String::new());
}
let after_h2_close = match block[h2_start..].find('>') {
Some(pos) => h2_start + pos + 1,
None => return (String::new(), String::new()),
};
let after_h2 = &block[after_h2_close..];
let after_a = match after_h2.find("<a ") {
Some(pos) => &after_h2[pos + 3..],
None => return (String::new(), String::new()),
};
let url = extract_attr_value(after_a, "href");
let after_gt = match after_a.find('>') {
Some(pos) => &after_a[pos + 1..],
None => return (String::new(), String::new()),
};
let title = match after_gt.find("</a>") {
Some(pos) => {
let raw = &after_gt[..pos];
strip_html_tags(raw)
}
None => String::new(),
};
(title, url)
}
fn extract_bing_snippet(block: &str) -> String {
let search_area = match block.find("b_caption") {
Some(pos) => &block[pos..],
None => block,
};
let p_start = find_tag_open(search_area, "p").unwrap_or(usize::MAX);
if p_start == usize::MAX {
return String::new();
};
let after_p_start = match search_area[p_start..].find('>') {
Some(pos) => p_start + pos + 1,
None => return String::new(),
};
let after_p = &search_area[after_p_start..];
match after_p.find("</p>") {
Some(pos) => {
let text = &after_p[..pos];
strip_html_tags(text)
}
None => String::new(),
}
}
fn extract_attr_value(s: &str, attr: &str) -> String {
let pattern = format!(r#"{attr}="#);
let pos = match s.find(&pattern) {
Some(p) => p + pattern.len(),
None => return String::new(),
};
let after = &s[pos..];
let chars: Vec<char> = after.chars().collect();
if chars.is_empty() {
return String::new();
}
if chars[0] == '"' || chars[0] == '\'' {
let quote = chars[0];
let mut val = String::new();
for &c in &chars[1..] {
if c == quote {
break;
}
val.push(c);
}
val
} else {
let mut val = String::new();
for &c in &chars {
if c == ' ' || c == '\t' || c == '>' || c == '/' {
break;
}
val.push(c);
}
val
}
}
fn strip_html_tags(s: &str) -> String {
let mut result = String::with_capacity(s.len());
let mut in_tag = false;
for c in s.chars() {
match c {
'<' => in_tag = true,
'>' => in_tag = false,
_ if !in_tag => result.push(c),
_ => {}
}
}
result.trim().to_string()
}
fn find_tag_open(s: &str, tag: &str) -> Option<usize> {
let exact = format!("<{tag}>");
let with_attrs = format!("<{tag} ");
let with_slash = format!("<{tag}/");
let pos1 = s.find(&exact);
let pos2 = s.find(&with_attrs);
let pos3 = s.find(&with_slash);
match (pos1, pos2, pos3) {
(Some(p1), Some(p2), _) => Some(p1.min(p2)),
(Some(p1), None, _) => Some(p1),
(None, Some(p2), _) => Some(p2),
(None, None, Some(p3)) => Some(p3),
(None, None, None) => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_extract_attr_value_double_quoted() {
assert_eq!(
extract_attr_value(r#"href="https://example.com" class="x""#, "href"),
"https://example.com"
);
}
#[test]
fn test_extract_attr_value_single_quoted() {
assert_eq!(
extract_attr_value(r#"href='https://example.com'"#, "href"),
"https://example.com"
);
}
#[test]
fn test_extract_attr_value_not_found() {
assert_eq!(extract_attr_value("<div>hello</div>", "href"), "");
}
#[test]
fn test_parse_bing_html_simple() {
let html = r#"
<ol id="b_results">
<li class="b_algo">
<h2><a href="https://example.com">Example Domain</a></h2>
<div class="b_caption"><p>This domain is for use in illustrative examples.</p></div>
</li>
</ol>"#;
let results = parse_bing_html(html, 10);
assert_eq!(results.len(), 1);
assert_eq!(results[0].title, "Example Domain");
assert_eq!(results[0].url, "https://example.com");
assert_eq!(results[0].snippet, "This domain is for use in illustrative examples.");
assert_eq!(results[0].source, "Bing");
}
#[test]
fn test_parse_bing_html_no_results() {
let html = "<html><body>No results found.</body></html>";
let results = parse_bing_html(html, 10);
assert!(results.is_empty());
}
#[test]
fn test_parse_bing_html_max_results() {
let html = r#"
<li class="b_algo"><h2><a href="https://a.com">A</a></h2><div class="b_caption"><p>First</p></div></li>
<li class="b_algo"><h2><a href="https://b.com">B</a></h2><div class="b_caption"><p>Second</p></div></li>
<li class="b_algo"><h2><a href="https://c.com">C</a></h2><div class="b_caption"><p>Third</p></div></li>
"#;
let results = parse_bing_html(html, 2);
assert_eq!(results.len(), 2);
assert_eq!(results[0].title, "A");
assert_eq!(results[1].title, "B");
}
#[test]
fn test_captcha_detection() {
let html = "<html>CaptchaChallenge</html>";
assert!(html.contains("CaptchaChallenge"));
let html2 = "<html> Bing captcha detected</html>";
assert!(html2.contains(" Bing captcha "));
}
}