use once_cell::sync::Lazy;
use scraper::{Html, Selector};
use url::Url;
use super::types::{SearchResult, SearchStatus};
use crate::compress::compress_text;
static RESULT_PARTS: Lazy<Selector> = Lazy::new(|| {
Selector::parse("a.result-link, a.result__a, .result-snippet, .result__snippet")
.expect("static selector")
});
static RESULTS_PAGE: Lazy<Selector> =
Lazy::new(|| Selector::parse(".result-count, .no-results, .results").expect("static selector"));
const CHALLENGE_MARKERS: [&str; 5] = [
"bots use duckduckgo",
"/anomaly",
"anomaly.js",
"unusual traffic",
"are you a robot",
];
pub fn resolve_result_url(href: &str) -> String {
let href = href.trim();
if href.is_empty() {
return String::new();
}
let absolute = if let Some(stripped) = href.strip_prefix("//") {
format!("https://{stripped}")
} else {
href.to_string()
};
if let Ok(parsed) = Url::parse(&absolute) {
if let Some((_, target)) = parsed.query_pairs().find(|(k, _)| k == "uddg") {
return target.into_owned();
}
return parsed.to_string();
}
absolute
}
pub fn parse_ddg_lite(html: &str, max_results: usize) -> Vec<SearchResult> {
let document = Html::parse_document(html);
let mut results: Vec<SearchResult> = Vec::new();
for el in document.select(&RESULT_PARTS) {
if el.value().name() == "a" {
let title = compress_text(&el.text().collect::<String>());
let url = resolve_result_url(el.value().attr("href").unwrap_or(""));
if title.is_empty() && url.is_empty() {
continue;
}
results.push(SearchResult {
title,
snippet: String::new(),
url,
ref_index: results.len() + 1,
});
} else if let Some(last) = results.last_mut() {
if last.snippet.is_empty() {
last.snippet = compress_text(&el.text().collect::<String>());
}
}
}
results.truncate(max_results);
results
}
pub fn classify_page(html: &str, result_count: usize) -> SearchStatus {
if result_count > 0 {
return SearchStatus::Ok;
}
let haystack = html.to_ascii_lowercase();
if CHALLENGE_MARKERS.iter().any(|m| haystack.contains(m)) {
return SearchStatus::Blocked;
}
if Html::parse_document(html)
.select(&RESULTS_PAGE)
.next()
.is_some()
{
return SearchStatus::Empty;
}
SearchStatus::Blocked
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn snippet_stays_with_its_own_link() {
let html = r#"<table>
<tr><td><a href="https://a.test/1" class="result-link">Alpha</a></td></tr>
<tr><td class="result-snippet">About ALPHA.</td></tr>
<tr><td><a href="https://b.test/2" class="result-link">Bravo</a></td></tr>
<tr><td><a href="https://c.test/3" class="result-link">Charlie</a></td></tr>
<tr><td class="result-snippet">About CHARLIE.</td></tr>
</table>"#;
let results = parse_ddg_lite(html, 10);
assert_eq!(results.len(), 3);
assert_eq!(results[0].snippet, "About ALPHA.");
assert_eq!(results[1].snippet, "", "Bravo must not inherit a snippet");
assert_eq!(results[2].snippet, "About CHARLIE.");
}
#[test]
fn a_stray_leading_snippet_is_ignored() {
let html = r#"<table>
<tr><td class="result-snippet">Orphan.</td></tr>
<tr><td><a href="https://a.test/1" class="result-link">Alpha</a></td></tr>
<tr><td class="result-snippet">About ALPHA.</td></tr>
</table>"#;
let results = parse_ddg_lite(html, 10);
assert_eq!(results.len(), 1);
assert_eq!(results[0].snippet, "About ALPHA.");
}
#[test]
fn alternate_class_names_are_recognized() {
let html = r#"<a href="https://a.test/1" class="result__a">Alpha</a>
<div class="result__snippet">About ALPHA.</div>"#;
let results = parse_ddg_lite(html, 10);
assert_eq!(results.len(), 1);
assert_eq!(results[0].snippet, "About ALPHA.");
}
#[test]
fn challenge_page_is_blocked_not_empty() {
let html = "<html><body><h1>Unfortunately, bots use DuckDuckGo too.</h1>\
<form action=\"/anomaly\"></form></body></html>";
assert!(parse_ddg_lite(html, 10).is_empty());
assert_eq!(classify_page(html, 0), SearchStatus::Blocked);
}
#[test]
fn genuine_no_results_page_is_empty() {
let html = "<html><body><table><tr><td class=\"result-count\">No results.</td>\
</tr></table></body></html>";
assert_eq!(classify_page(html, 0), SearchStatus::Empty);
}
#[test]
fn unrecognized_markup_is_blocked() {
assert_eq!(
classify_page("<html><body>something else entirely</body></html>", 0),
SearchStatus::Blocked
);
}
#[test]
fn results_present_is_always_ok() {
assert_eq!(classify_page("<html>whatever</html>", 3), SearchStatus::Ok);
}
}