返回 CodeWhale
scrape.rs
根目录 / crates / tui / src / tools / web / scrape.rs
1 //! Shared DuckDuckGo / Bing HTML SERP scrapers and spam filter.
2 //!
3 //! Used by both `web_search` and `web_run` so parser behavior (including the
4 //! #964 evidence-based spam filter) cannot drift between the two paths.
5
6 use base64::{Engine as _, engine::general_purpose};
7 use regex::Regex;
8 use std::sync::OnceLock;
9
10 /// Shared browser-like user agent for public SERP scraping and image search.
11 /// Both `web_search` and `web_run` send this so scrape behavior (including
12 /// bot-challenge rates) stays identical between the two surfaces.
13 pub(crate) const BROWSER_USER_AGENT: &str = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15";
14
15 /// One parsed search hit: title, absolute URL, optional snippet.
16 #[derive(Debug, Clone, PartialEq, Eq)]
17 pub struct ScrapedSearchResult {
18 pub title: String,
19 pub url: String,
20 pub snippet: Option<String>,
21 }
22
23 // Cached regex patterns for HTML parsing
24 static TITLE_RE: OnceLock<Regex> = OnceLock::new();
25 static SNIPPET_RE: OnceLock<Regex> = OnceLock::new();
26 static TAG_RE: OnceLock<Regex> = OnceLock::new();
27 static BING_RESULT_RE: OnceLock<Regex> = OnceLock::new();
28 static BING_TITLE_RE: OnceLock<Regex> = OnceLock::new();
29 static BING_SNIPPET_RE: OnceLock<Regex> = OnceLock::new();
30
31 fn get_title_re() -> &'static Regex {
32 TITLE_RE.get_or_init(|| {
33 Regex::new(r#"<a[^>]*class=\"result__a\"[^>]*href=\"([^\"]+)\"[^>]*>(.*?)</a>"#)
34 .expect("title regex pattern is valid")
35 })
36 }
37
38 fn get_snippet_re() -> &'static Regex {
39 SNIPPET_RE.get_or_init(|| {
40 Regex::new(
41 r#"<a[^>]*class=\"result__snippet\"[^>]*>(.*?)</a>|<div[^>]*class=\"result__snippet\"[^>]*>(.*?)</div>"#,
42 )
43 .expect("snippet regex pattern is valid")
44 })
45 }
46
47 fn get_tag_re() -> &'static Regex {
48 TAG_RE.get_or_init(|| Regex::new(r"<[^>]+>").expect("tag regex pattern is valid"))
49 }
50
51 fn get_bing_result_re() -> &'static Regex {
52 BING_RESULT_RE.get_or_init(|| {
53 Regex::new(r#"(?is)<li[^>]*class=\"[^\"]*\bb_algo\b[^\"]*\"[^>]*>(.*?)</li>"#)
54 .expect("bing result regex pattern is valid")
55 })
56 }
57
58 fn get_bing_title_re() -> &'static Regex {
59 BING_TITLE_RE.get_or_init(|| {
60 Regex::new(r#"(?is)<h2[^>]*>.*?<a[^>]*href=\"([^\"]+)\"[^>]*>(.*?)</a>"#)
61 .expect("bing title regex pattern is valid")
62 })
63 }
64
65 fn get_bing_snippet_re() -> &'static Regex {
66 BING_SNIPPET_RE.get_or_init(|| {
67 Regex::new(r#"(?is)<div[^>]*class=\"[^\"]*\bb_caption\b[^\"]*\"[^>]*>.*?<p[^>]*>(.*?)</p>"#)
68 .expect("bing snippet regex pattern is valid")
69 })
70 }
71
72 /// Parse DuckDuckGo HTML SERP results. Known spam-domain hits are omitted.
73 pub fn parse_duckduckgo_results(html: &str, max_results: usize) -> Vec<ScrapedSearchResult> {
74 let title_re = get_title_re();
75 let snippet_re = get_snippet_re();
76 let snippets: Vec<String> = snippet_re
77 .captures_iter(html)
78 .filter_map(|cap| cap.get(1).or_else(|| cap.get(2)))
79 .map(|m| normalize_text(m.as_str()))
80 .collect();
81
82 let mut results = Vec::new();
83 for (idx, cap) in title_re.captures_iter(html).enumerate() {
84 if results.len() >= max_results {
85 break;
86 }
87 let href = cap.get(1).map(|m| m.as_str()).unwrap_or("");
88 let title_raw = cap.get(2).map(|m| m.as_str()).unwrap_or("");
89 let title = normalize_text(title_raw);
90 if title.is_empty() {
91 continue;
92 }
93 let url = normalize_duckduckgo_url(href);
94 if is_known_spam_url(&url) {
95 continue;
96 }
97 let snippet = snippets
98 .get(idx)
99 .map(|s| s.to_string())
100 .filter(|s| !s.is_empty());
101
102 results.push(ScrapedSearchResult {
103 title,
104 url,
105 snippet,
106 });
107 }
108
109 results
110 }
111
112 /// Parse Bing HTML SERP results. Known spam-domain hits are omitted.
113 pub fn parse_bing_results(html: &str, max_results: usize) -> Vec<ScrapedSearchResult> {
114 let mut results = Vec::new();
115 for cap in get_bing_result_re().captures_iter(html) {
116 if results.len() >= max_results {
117 break;
118 }
119 let Some(block) = cap.get(1).map(|m| m.as_str()) else {
120 continue;
121 };
122 let Some(title_cap) = get_bing_title_re().captures(block) else {
123 continue;
124 };
125 let href = title_cap.get(1).map(|m| m.as_str()).unwrap_or("");
126 let title_raw = title_cap.get(2).map(|m| m.as_str()).unwrap_or("");
127 let title = normalize_text(title_raw);
128 if title.is_empty() {
129 continue;
130 }
131 let snippet = get_bing_snippet_re()
132 .captures(block)
133 .and_then(|snippet_cap| snippet_cap.get(1))
134 .map(|m| normalize_text(m.as_str()))
135 .filter(|s| !s.is_empty());
136
137 let url = normalize_bing_url(href);
138 if is_known_spam_url(&url) {
139 continue;
140 }
141 results.push(ScrapedSearchResult {
142 title,
143 url,
144 snippet,
145 });
146 }
147 results
148 }
149
150 /// Detect DuckDuckGo bot-challenge interstitial HTML.
151 pub fn is_duckduckgo_challenge(html: &str) -> bool {
152 html.contains("anomaly-modal") || html.contains("Unfortunately, bots use DuckDuckGo too")
153 }
154
155 /// Evidence-based filter for the domain family in #964. A broad same-domain
156 /// ratio is not a spam signal: site-scoped searches and documentation hosts
157 /// legitimately return many results from one registrable domain.
158 fn is_known_spam_url(url: &str) -> bool {
159 const KNOWN_SPAM_DOMAINS: &[&str] = &["forumgratuit.org"];
160
161 let Ok(parsed) = reqwest::Url::parse(url) else {
162 return false;
163 };
164 let Some(host) = parsed.host_str() else {
165 return false;
166 };
167 KNOWN_SPAM_DOMAINS
168 .iter()
169 .any(|domain| host == *domain || host.ends_with(&format!(".{domain}")))
170 }
171
172 fn normalize_duckduckgo_url(href: &str) -> String {
173 if let Some(uddg) = extract_query_param(href, "uddg") {
174 let decoded = percent_decode(&uddg);
175 if !decoded.is_empty() {
176 return decoded;
177 }
178 }
179 if href.starts_with("//") {
180 return format!("https:{href}");
181 }
182 if href.starts_with('/') {
183 return format!("https://duckduckgo.com{href}");
184 }
185 href.to_string()
186 }
187
188 /// Normalize a Bing SERP result href, unwrapping `/ck/a?...&u=<base64>` redirects.
189 pub fn normalize_bing_url(href: &str) -> String {
190 // Bing wraps every SERP result URL in a `/ck/a?...&u=<base64>` click-tracking
191 // redirect, and in the raw HTML the separators are `&amp;` entities. Without
192 // decoding entities first, `extract_query_param` looks for `u` but the actual
193 // key is `amp;u`, so the real URL is never recovered and callers receive Bing's
194 // tracking URL instead of the cited source. Decode entities before parsing.
195 let href = decode_html_entities(href);
196 let href = href.as_str();
197 if let Some(encoded) = extract_query_param(href, "u") {
198 let decoded = percent_decode(&encoded);
199 let token = decoded.strip_prefix("a1").unwrap_or(&decoded);
200 let mut padded = token.replace('-', "+").replace('_', "/");
201 while !padded.len().is_multiple_of(4) {
202 padded.push('=');
203 }
204 if let Ok(bytes) = general_purpose::STANDARD.decode(padded)
205 && let Ok(url) = String::from_utf8(bytes)
206 && (url.starts_with("http://") || url.starts_with("https://"))
207 {
208 return url;
209 }
210 }
211 if href.starts_with("//") {
212 return format!("https:{href}");
213 }
214 if href.starts_with('/') {
215 return format!("https://www.bing.com{href}");
216 }
217 href.to_string()
218 }
219
220 fn normalize_text(text: &str) -> String {
221 let stripped = strip_html_tags(text);
222 let decoded = decode_html_entities(&stripped);
223 decoded.split_whitespace().collect::<Vec<_>>().join(" ")
224 }
225
226 fn strip_html_tags(text: &str) -> String {
227 get_tag_re().replace_all(text, "").to_string()
228 }
229
230 /// Decode common HTML named and numeric character references.
231 pub fn decode_html_entities(text: &str) -> String {
232 static ENTITY_RE: OnceLock<Regex> = OnceLock::new();
233 let re = ENTITY_RE.get_or_init(|| {
234 Regex::new(r"&(?:#(\d+)|#x([0-9A-Fa-f]+)|([a-zA-Z]+));").expect("HTML entity regex")
235 });
236
237 re.replace_all(text, |caps: &regex::Captures| {
238 if let Some(dec) = caps.get(1) {
239 return dec
240 .as_str()
241 .parse::<u32>()
242 .ok()
243 .and_then(std::char::from_u32)
244 .unwrap_or('\u{FFFD}')
245 .to_string();
246 }
247 if let Some(hex) = caps.get(2) {
248 return u32::from_str_radix(hex.as_str(), 16)
249 .ok()
250 .and_then(std::char::from_u32)
251 .unwrap_or('\u{FFFD}')
252 .to_string();
253 }
254 let named = caps.get(3).map(|m| m.as_str());
255 match named {
256 Some("amp") => "&",
257 Some("lt") => "<",
258 Some("gt") => ">",
259 Some("quot") => "\"",
260 Some("apos") => "'",
261 Some("nbsp") => " ",
262 Some("copy") => "\u{00A9}",
263 Some("reg") => "\u{00AE}",
264 Some("mdash") => "\u{2014}",
265 Some("ndash") => "\u{2013}",
266 Some("lsquo") => "\u{2018}",
267 Some("rsquo") => "\u{2019}",
268 Some("ldquo") => "\u{201C}",
269 Some("rdquo") => "\u{201D}",
270 Some("hellip") => "\u{2026}",
271 _ => return caps.get(0).map(|m| m.as_str()).unwrap_or("").to_string(),
272 }
273 .to_string()
274 })
275 .to_string()
276 }
277
278 /// Percent-decode a URL component. `+` becomes space (query-string convention).
279 pub fn percent_decode(input: &str) -> String {
280 let bytes = input.as_bytes();
281 let mut out = Vec::new();
282 let mut i = 0;
283 while i < bytes.len() {
284 match bytes[i] {
285 b'%' if i + 2 < bytes.len() => {
286 let hex = &input[i + 1..i + 3];
287 if let Ok(val) = u8::from_str_radix(hex, 16) {
288 out.push(val);
289 i += 3;
290 continue;
291 }
292 out.push(bytes[i]);
293 }
294 b'+' => out.push(b' '),
295 _ => out.push(bytes[i]),
296 }
297 i += 1;
298 }
299 String::from_utf8_lossy(&out).to_string()
300 }
301
302 fn extract_query_param(url: &str, key: &str) -> Option<String> {
303 let query = url.split_once('?')?.1;
304 for part in query.split('&') {
305 let mut iter = part.splitn(2, '=');
306 let name = iter.next().unwrap_or("");
307 if name == key {
308 return iter.next().map(str::to_string);
309 }
310 }
311 None
312 }
313
314 #[cfg(test)]
315 mod tests {
316 use super::*;
317
318 // Regression guard: Bing /ck/a redirect hrefs are HTML-entity-encoded
319 // (`&amp;`). normalize_bing_url must decode entities before extracting the
320 // `u=` base64 payload, otherwise the real URL is never recovered and the
321 // result remains a Bing tracking URL instead of the cited source.
322 #[test]
323 fn bing_ckurl_with_html_entities_decodes_real_url() {
324 let href = "https://www.bing.com/ck/a?!&amp;&amp;p=abc&amp;u=a1aHR0cHM6Ly9ydXN0LWxhbmcub3JnLw&amp;ntb=1";
325 assert_eq!(normalize_bing_url(href), "https://rust-lang.org/");
326 }
327
328 #[test]
329 fn parses_bing_results_and_decodes_redirect_url() {
330 let html = r#"
331 <ol>
332 <li class="b_algo">
333 <h2><a href="https://www.bing.com/ck/a?u=a1aHR0cHM6Ly9leGFtcGxlLmNvbS9wYXRoP3E9MQ">Example &amp; Result</a></h2>
334 <div class="b_caption"><p>A <strong>useful</strong> snippet.</p></div>
335 </li>
336 </ol>
337 "#;
338
339 let results = parse_bing_results(html, 5);
340
341 assert_eq!(results.len(), 1);
342 assert_eq!(results[0].title, "Example & Result");
343 assert_eq!(results[0].url, "https://example.com/path?q=1");
344 assert_eq!(results[0].snippet.as_deref(), Some("A useful snippet."));
345 }
346
347 #[test]
348 fn parses_duckduckgo_results() {
349 let html = r#"
350 <a class="result__a" href="https://example.com/rust">Rust &amp; async</a>
351 <a class="result__snippet">A <b>useful</b> snippet.</a>
352 <a class="result__a" href="//docs.rs/tokio">Tokio</a>
353 <div class="result__snippet">Runtime docs</div>
354 "#;
355 let results = parse_duckduckgo_results(html, 5);
356 assert_eq!(results.len(), 2);
357 assert_eq!(results[0].title, "Rust & async");
358 assert_eq!(results[0].url, "https://example.com/rust");
359 assert_eq!(results[0].snippet.as_deref(), Some("A useful snippet."));
360 assert_eq!(results[1].url, "https://docs.rs/tokio");
361 }
362
363 #[test]
364 fn known_spam_filter_is_domain_specific() {
365 assert!(is_known_spam_url("https://astralia.forumgratuit.org/page1"));
366 assert!(!is_known_spam_url("https://forumgratuit.org.example/a"));
367 assert!(!is_known_spam_url("https://docs.example.com/a"));
368 }
369
370 #[test]
371 fn legitimate_same_domain_results_are_preserved() {
372 let html = r#"
373 <a class="result__a" href="https://docs.example.com/a">A</a>
374 <a class="result__snippet">s</a>
375 <a class="result__a" href="https://docs.example.com/b">B</a>
376 <a class="result__snippet">s</a>
377 <a class="result__a" href="https://docs.example.com/c">C</a>
378 <a class="result__snippet">s</a>
379 <a class="result__a" href="https://docs.example.com/d">D</a>
380 <a class="result__snippet">s</a>
381 "#;
382 assert_eq!(parse_duckduckgo_results(html, 10).len(), 4);
383 }
384
385 #[test]
386 fn public_suffix_and_private_suffix_hosts_are_not_misgrouped() {
387 let html = r#"
388 <a class="result__a" href="https://alpha.example.co.uk/a">A</a>
389 <a class="result__snippet">s</a>
390 <a class="result__a" href="https://beta.example.co.uk/b">B</a>
391 <a class="result__snippet">s</a>
392 <a class="result__a" href="https://alice.github.io/c">C</a>
393 <a class="result__snippet">s</a>
394 <a class="result__a" href="https://bob.github.io/d">D</a>
395 <a class="result__snippet">s</a>
396 "#;
397 assert_eq!(parse_duckduckgo_results(html, 10).len(), 4);
398 }
399
400 #[test]
401 fn max_results_three_keeps_legitimate_same_domain_results() {
402 let html = r#"
403 <a class="result__a" href="https://docs.example.com/a">A</a>
404 <a class="result__snippet">s</a>
405 <a class="result__a" href="https://docs.example.com/b">B</a>
406 <a class="result__snippet">s</a>
407 <a class="result__a" href="https://docs.example.com/c">C</a>
408 <a class="result__snippet">s</a>
409 <a class="result__a" href="https://docs.example.com/d">D</a>
410 <a class="result__snippet">s</a>
411 "#;
412 assert_eq!(parse_duckduckgo_results(html, 3).len(), 3);
413 }
414
415 #[test]
416 fn parse_duckduckgo_filters_known_spam_domain() {
417 // Shared path used by web_run and web_search: known spam → empty.
418 let html = r#"
419 <a class="result__a" href="https://astralia.forumgratuit.org/a">A</a>
420 <a class="result__snippet">s</a>
421 <a class="result__a" href="https://russia.forumgratuit.org/b">B</a>
422 <a class="result__snippet">s</a>
423 <a class="result__a" href="https://other.forumgratuit.org/c">C</a>
424 <a class="result__snippet">s</a>
425 <a class="result__a" href="https://hello.forumgratuit.org/d">D</a>
426 <a class="result__snippet">s</a>
427 <a class="result__a" href="https://world.forumgratuit.org/e">E</a>
428 <a class="result__snippet">s</a>
429 "#;
430 assert!(parse_duckduckgo_results(html, 10).is_empty());
431 }
432
433 #[test]
434 fn parse_bing_filters_known_spam_domain() {
435 let html = r#"
436 <ol>
437 <li class="b_algo">
438 <h2><a href="https://astralia.forumgratuit.org/a">A</a></h2>
439 <div class="b_caption"><p>s</p></div>
440 </li>
441 <li class="b_algo">
442 <h2><a href="https://russia.forumgratuit.org/b">B</a></h2>
443 <div class="b_caption"><p>s</p></div>
444 </li>
445 <li class="b_algo">
446 <h2><a href="https://other.forumgratuit.org/c">C</a></h2>
447 <div class="b_caption"><p>s</p></div>
448 </li>
449 <li class="b_algo">
450 <h2><a href="https://hello.forumgratuit.org/d">D</a></h2>
451 <div class="b_caption"><p>s</p></div>
452 </li>
453 <li class="b_algo">
454 <h2><a href="https://world.forumgratuit.org/e">E</a></h2>
455 <div class="b_caption"><p>s</p></div>
456 </li>
457 </ol>
458 "#;
459 assert!(parse_bing_results(html, 10).is_empty());
460 }
461
462 #[test]
463 fn decode_html_entities_handles_named_entities() {
464 assert_eq!(decode_html_entities("&amp;"), "&");
465 assert_eq!(decode_html_entities("&lt;"), "<");
466 assert_eq!(decode_html_entities("&gt;"), ">");
467 assert_eq!(decode_html_entities("&quot;"), "\"");
468 assert_eq!(decode_html_entities("&apos;"), "'");
469 assert_eq!(decode_html_entities("&nbsp;"), " ");
470 assert_eq!(decode_html_entities("&copy;"), "\u{00A9}");
471 assert_eq!(decode_html_entities("&mdash;"), "\u{2014}");
472 }
473
474 #[test]
475 fn decode_html_entities_handles_decimal_numeric_references() {
476 assert_eq!(decode_html_entities("&#65;"), "A");
477 assert_eq!(decode_html_entities("&#60;"), "<");
478 assert_eq!(decode_html_entities("&#8211;"), "\u{2013}");
479 }
480
481 #[test]
482 fn decode_html_entities_handles_hex_numeric_references() {
483 assert_eq!(decode_html_entities("&#x41;"), "A");
484 assert_eq!(decode_html_entities("&#x3C;"), "<");
485 assert_eq!(decode_html_entities("&#x2014;"), "\u{2014}");
486 }
487
488 #[test]
489 fn decode_html_entities_passthrough_unknown() {
490 assert_eq!(decode_html_entities("&unknown;"), "&unknown;");
491 }
492
493 #[test]
494 fn decode_html_entities_mixed_content() {
495 let input = "Hello &amp; welcome to &quot;Rust&apos;s world&quot; &mdash; enjoy!";
496 let expected = "Hello & welcome to \"Rust's world\" \u{2014} enjoy!";
497 assert_eq!(decode_html_entities(input), expected);
498 }
499
500 #[test]
501 fn percent_decode_handles_utf8_multibyte_sequences() {
502 // Percent-encoded CJK: %E4%B8%AA%E4%BA%BA = 个人 (each glyph is 3 UTF-8 bytes).
503 assert_eq!(percent_decode("Hello %E4%B8%AA%E4%BA%BA"), "Hello 个人");
504 assert_eq!(percent_decode("%E7%B4%A0%E6%9D%90"), "素材");
505 // Percent-encoded UTF-8 inside a URL path (DuckDuckGo `uddg=` redirect shape).
506 assert_eq!(
507 percent_decode("https://example.com/%E9%A1%B5%E9%9D%A2"),
508 "https://example.com/页面"
509 );
510 // Raw UTF-8 in the input passes through unchanged.
511 assert_eq!(percent_decode("查询 keyword"), "查询 keyword");
512 // Query-string convention: `+` becomes space; `%20` becomes space.
513 assert_eq!(percent_decode("foo+bar%20baz"), "foo bar baz");
514 }
515
516 #[test]
517 fn is_duckduckgo_challenge_detects_interstitial() {
518 assert!(is_duckduckgo_challenge(
519 "Unfortunately, bots use DuckDuckGo too."
520 ));
521 assert!(is_duckduckgo_challenge(
522 r#"<div class="anomaly-modal">challenge</div>"#
523 ));
524 assert!(!is_duckduckgo_challenge(
525 r#"<a class="result__a" href="https://example.com">ok</a>"#
526 ));
527 }
528 }
529
529 lines RUST