use std::sync::OnceLock;
use async_trait::async_trait;
use super::super::utils;
use super::super::{filter_by_domains, SearchError, SearchOptions, SearchResult, WebSearchAdapter};
pub struct DuckDuckGoAdapter {
client: reqwest::Client,
base_url: String,
}
impl DuckDuckGoAdapter {
pub fn new() -> Result<Self, SearchError> {
let client = super::super::shared_search_client()?;
Ok(Self {
client,
base_url: "https://html.duckduckgo.com/html/".into(),
})
}
pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
self.base_url = base_url.into();
self
}
}
#[async_trait]
impl WebSearchAdapter for DuckDuckGoAdapter {
fn name(&self) -> &'static str {
"duckduckgo"
}
async fn search(
&self,
query: &str,
options: &SearchOptions,
) -> Result<Vec<SearchResult>, SearchError> {
let max = options.max_results.unwrap_or(5);
let sep = if self.base_url.contains('?') {
'&'
} else {
'?'
};
let url = format!("{}{}q={}", self.base_url, sep, urlencoding::encode(query));
let resp = self
.client
.get(&url)
.send()
.await
.map_err(|e| SearchError::Http(e.to_string()))?;
let status = resp.status();
if status == reqwest::StatusCode::FORBIDDEN {
return Err(SearchError::BlockedByAntiBot);
}
if !status.is_success() {
return Err(SearchError::Http(format!("HTTP {status}")));
}
let html = resp
.text()
.await
.map_err(|e| SearchError::Http(e.to_string()))?;
if is_ddg_anti_bot_page(&html) {
return Err(SearchError::BlockedByAntiBot);
}
let mut results = parse_ddg_html(&html, max);
results = filter_by_domains(results, &options.allowed_domains, &options.blocked_domains);
results.truncate(max);
Ok(results)
}
}
fn is_ddg_anti_bot_page(html: &str) -> bool {
let lower = html.to_ascii_lowercase();
lower.contains("anomaly") || lower.contains("captcha")
}
fn anchor_re() -> &'static regex::Regex {
static RE: OnceLock<regex::Regex> = OnceLock::new();
RE.get_or_init(|| {
regex::Regex::new(r#"(?is)<a\b([^>]*class="result__a"[^>]*)>(.*?)</a>"#)
.expect("valid regex")
})
}
fn href_re() -> &'static regex::Regex {
static RE: OnceLock<regex::Regex> = OnceLock::new();
RE.get_or_init(|| regex::Regex::new(r##"href\s*=\s*"([^"]*)""##).expect("valid regex"))
}
fn snippet_re() -> &'static regex::Regex {
static RE: OnceLock<regex::Regex> = OnceLock::new();
RE.get_or_init(|| {
regex::Regex::new(r#"(?is)<a\b[^>]*class="result__snippet"[^>]*>(.*?)</a>"#)
.expect("valid regex")
})
}
pub fn parse_ddg_html(html: &str, limit: usize) -> Vec<SearchResult> {
let snippets: Vec<String> = snippet_re()
.captures_iter(html)
.map(|c| utils::strip_html_tags(&c[1]).trim().to_string())
.collect();
let mut results = Vec::new();
for (i, cap) in anchor_re().captures_iter(html).enumerate() {
if results.len() >= limit {
break;
}
let attrs = &cap[1];
let title = utils::strip_html_tags(&cap[2]).trim().to_string();
let Some(href) = href_re().captures(attrs).map(|c| c[1].to_string()) else {
continue;
};
let Some(url) = resolve_ddg_url(&href) else {
continue;
};
if title.is_empty() {
continue;
}
let snippet = snippets.get(i).filter(|s| !s.is_empty()).cloned();
results.push(SearchResult {
title,
url,
snippet,
});
}
results
}
pub fn resolve_ddg_url(href: &str) -> Option<String> {
if let Some(pos) = href.find("uddg=") {
let rest = &href[pos + "uddg=".len()..];
let end = rest.find('&').unwrap_or(rest.len());
let real = urlencoding::decode(&rest[..end]).ok()?;
let real = real.trim();
if real.starts_with("http") {
return Some(real.to_string());
}
return None;
}
if let Some(stripped) = href.strip_prefix("//") {
return Some(format!("https://{stripped}"));
}
if href.starts_with("http") {
return Some(href.to_string());
}
None
}