1use once_cell::sync::Lazy;
5use scraper::{Html, Selector};
6use url::Url;
7
8use super::types::{SearchResult, SearchStatus};
9use crate::compress::compress_text;
10
11static RESULT_PARTS: Lazy<Selector> = Lazy::new(|| {
14 Selector::parse("a.result-link, a.result__a, .result-snippet, .result__snippet")
15 .expect("static selector")
16});
17
18static RESULTS_PAGE: Lazy<Selector> =
21 Lazy::new(|| Selector::parse(".result-count, .no-results, .results").expect("static selector"));
22
23const CHALLENGE_MARKERS: [&str; 5] = [
26 "bots use duckduckgo",
27 "/anomaly",
28 "anomaly.js",
29 "unusual traffic",
30 "are you a robot",
31];
32
33pub fn resolve_result_url(href: &str) -> String {
41 let href = href.trim();
42 if href.is_empty() {
43 return String::new();
44 }
45
46 let absolute = if let Some(stripped) = href.strip_prefix("//") {
48 format!("https://{stripped}")
49 } else {
50 href.to_string()
51 };
52
53 if let Ok(parsed) = Url::parse(&absolute) {
54 if let Some((_, target)) = parsed.query_pairs().find(|(k, _)| k == "uddg") {
55 return target.into_owned();
56 }
57 return parsed.to_string();
58 }
59
60 absolute
61}
62
63pub fn parse_ddg_lite(html: &str, max_results: usize) -> Vec<SearchResult> {
71 let document = Html::parse_document(html);
72 let mut results: Vec<SearchResult> = Vec::new();
73
74 for el in document.select(&RESULT_PARTS) {
75 if el.value().name() == "a" {
76 let title = compress_text(&el.text().collect::<String>());
77 let url = resolve_result_url(el.value().attr("href").unwrap_or(""));
78 if title.is_empty() && url.is_empty() {
79 continue;
80 }
81 results.push(SearchResult {
82 title,
83 snippet: String::new(),
84 url,
85 ref_index: results.len() + 1,
86 });
87 } else if let Some(last) = results.last_mut() {
88 if last.snippet.is_empty() {
91 last.snippet = compress_text(&el.text().collect::<String>());
92 }
93 }
94 }
95
96 results.truncate(max_results);
97 results
98}
99
100pub fn classify_page(html: &str, result_count: usize) -> SearchStatus {
109 if result_count > 0 {
110 return SearchStatus::Ok;
111 }
112 let haystack = html.to_ascii_lowercase();
113 if CHALLENGE_MARKERS.iter().any(|m| haystack.contains(m)) {
114 return SearchStatus::Blocked;
115 }
116 if Html::parse_document(html)
119 .select(&RESULTS_PAGE)
120 .next()
121 .is_some()
122 {
123 return SearchStatus::Empty;
124 }
125 SearchStatus::Blocked
126}
127
128#[cfg(test)]
129mod tests {
130 use super::*;
131
132 #[test]
133 fn snippet_stays_with_its_own_link() {
134 let html = r#"<table>
136 <tr><td><a href="https://a.test/1" class="result-link">Alpha</a></td></tr>
137 <tr><td class="result-snippet">About ALPHA.</td></tr>
138 <tr><td><a href="https://b.test/2" class="result-link">Bravo</a></td></tr>
139 <tr><td><a href="https://c.test/3" class="result-link">Charlie</a></td></tr>
140 <tr><td class="result-snippet">About CHARLIE.</td></tr>
141 </table>"#;
142 let results = parse_ddg_lite(html, 10);
143 assert_eq!(results.len(), 3);
144 assert_eq!(results[0].snippet, "About ALPHA.");
145 assert_eq!(results[1].snippet, "", "Bravo must not inherit a snippet");
146 assert_eq!(results[2].snippet, "About CHARLIE.");
147 }
148
149 #[test]
150 fn a_stray_leading_snippet_is_ignored() {
151 let html = r#"<table>
152 <tr><td class="result-snippet">Orphan.</td></tr>
153 <tr><td><a href="https://a.test/1" class="result-link">Alpha</a></td></tr>
154 <tr><td class="result-snippet">About ALPHA.</td></tr>
155 </table>"#;
156 let results = parse_ddg_lite(html, 10);
157 assert_eq!(results.len(), 1);
158 assert_eq!(results[0].snippet, "About ALPHA.");
159 }
160
161 #[test]
162 fn alternate_class_names_are_recognized() {
163 let html = r#"<a href="https://a.test/1" class="result__a">Alpha</a>
165 <div class="result__snippet">About ALPHA.</div>"#;
166 let results = parse_ddg_lite(html, 10);
167 assert_eq!(results.len(), 1);
168 assert_eq!(results[0].snippet, "About ALPHA.");
169 }
170
171 #[test]
172 fn challenge_page_is_blocked_not_empty() {
173 let html = "<html><body><h1>Unfortunately, bots use DuckDuckGo too.</h1>\
174 <form action=\"/anomaly\"></form></body></html>";
175 assert!(parse_ddg_lite(html, 10).is_empty());
176 assert_eq!(classify_page(html, 0), SearchStatus::Blocked);
177 }
178
179 #[test]
180 fn genuine_no_results_page_is_empty() {
181 let html = "<html><body><table><tr><td class=\"result-count\">No results.</td>\
182 </tr></table></body></html>";
183 assert_eq!(classify_page(html, 0), SearchStatus::Empty);
184 }
185
186 #[test]
187 fn unrecognized_markup_is_blocked() {
188 assert_eq!(
191 classify_page("<html><body>something else entirely</body></html>", 0),
192 SearchStatus::Blocked
193 );
194 }
195
196 #[test]
197 fn results_present_is_always_ok() {
198 assert_eq!(classify_page("<html>whatever</html>", 3), SearchStatus::Ok);
199 }
200}