web_search/providers/
html_utils.rs1use std::collections::HashSet;
9use std::sync::LazyLock;
10
11use regex::Regex;
12
13use super::base::SearchResult;
14
15pub fn decode_html_entities(input: &str) -> String {
18 if input.is_empty() {
19 return String::new();
20 }
21
22 let named = [
23 ("&", "&"),
24 ("<", "<"),
25 (">", ">"),
26 (""", "\""),
27 ("'", "'"),
28 ("'", "'"),
29 (" ", " "),
30 ("…", "…"),
31 ("—", "—"),
32 ("–", "–"),
33 ];
34
35 let mut output = input.to_string();
36 for (entity, replacement) in named {
37 output = output.replace(entity, replacement);
38 }
39
40 static NUMERIC: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"&#(x?[0-9a-fA-F]+);").unwrap());
41 NUMERIC
42 .replace_all(&output, |caps: ®ex::Captures| {
43 let token = &caps[1];
44 let code =
45 if let Some(hex) = token.strip_prefix('x').or_else(|| token.strip_prefix('X')) {
46 u32::from_str_radix(hex, 16).ok()
47 } else {
48 token.parse::<u32>().ok()
49 };
50 code.and_then(char::from_u32)
51 .map(|c| c.to_string())
52 .unwrap_or_else(|| caps[0].to_string())
53 })
54 .into_owned()
55}
56
57pub fn strip_html(input: &str) -> String {
59 static TAG: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"<[^>]*>").unwrap());
60 TAG.replace_all(input, "").into_owned()
61}
62
63pub fn clean_text(input: &str) -> String {
66 static WS: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\s+").unwrap());
67 let stripped = strip_html(input);
68 let decoded = decode_html_entities(&stripped);
69 WS.replace_all(&decoded, " ").trim().to_string()
70}
71
72pub struct AnchorConfig {
75 pub source: &'static str,
77 pub limit: usize,
79 pub item_regex: &'static Regex,
81 pub url_group: usize,
83 pub title_group: usize,
85 pub snippet_group: Option<usize>,
87 pub url_transform: Option<fn(&str) -> String>,
89 pub skip: Option<fn(&str) -> bool>,
91}
92
93pub fn parse_anchor_list(html: &str, config: &AnchorConfig) -> Vec<SearchResult> {
95 let mut results = Vec::new();
96 let mut seen = HashSet::new();
97
98 for caps in config.item_regex.captures_iter(html) {
99 if results.len() >= config.limit {
100 break;
101 }
102
103 let raw_url = match caps.get(config.url_group) {
104 Some(m) => m.as_str(),
105 None => continue,
106 };
107 let url = match config.url_transform {
108 Some(transform) => transform(raw_url),
109 None => raw_url.to_string(),
110 };
111
112 if url.is_empty() || seen.contains(&url) {
113 continue;
114 }
115 if let Some(skip) = config.skip {
116 if skip(&url) {
117 continue;
118 }
119 }
120 seen.insert(url.clone());
121
122 let title = caps
123 .get(config.title_group)
124 .map(|m| clean_text(m.as_str()))
125 .filter(|t| !t.is_empty())
126 .unwrap_or_else(|| "Untitled".to_string());
127 let snippet = config
128 .snippet_group
129 .and_then(|g| caps.get(g))
130 .map(|m| clean_text(m.as_str()))
131 .unwrap_or_default();
132
133 results.push(SearchResult {
134 title,
135 url,
136 snippet,
137 source: config.source.to_string(),
138 rank: results.len() + 1,
139 score: None,
140 sources: None,
141 });
142 }
143
144 results
145}