use std::collections::HashSet;
use std::sync::LazyLock;
use regex::Regex;
use super::base::SearchResult;
pub fn decode_html_entities(input: &str) -> String {
if input.is_empty() {
return String::new();
}
let named = [
("&", "&"),
("<", "<"),
(">", ">"),
(""", "\""),
("'", "'"),
("'", "'"),
(" ", " "),
("…", "…"),
("—", "—"),
("–", "–"),
];
let mut output = input.to_string();
for (entity, replacement) in named {
output = output.replace(entity, replacement);
}
static NUMERIC: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"&#(x?[0-9a-fA-F]+);").unwrap());
NUMERIC
.replace_all(&output, |caps: ®ex::Captures| {
let token = &caps[1];
let code =
if let Some(hex) = token.strip_prefix('x').or_else(|| token.strip_prefix('X')) {
u32::from_str_radix(hex, 16).ok()
} else {
token.parse::<u32>().ok()
};
code.and_then(char::from_u32)
.map(|c| c.to_string())
.unwrap_or_else(|| caps[0].to_string())
})
.into_owned()
}
pub fn strip_html(input: &str) -> String {
static TAG: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"<[^>]*>").unwrap());
TAG.replace_all(input, "").into_owned()
}
pub fn clean_text(input: &str) -> String {
static WS: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\s+").unwrap());
let stripped = strip_html(input);
let decoded = decode_html_entities(&stripped);
WS.replace_all(&decoded, " ").trim().to_string()
}
pub struct AnchorConfig {
pub source: &'static str,
pub limit: usize,
pub item_regex: &'static Regex,
pub url_group: usize,
pub title_group: usize,
pub snippet_group: Option<usize>,
pub url_transform: Option<fn(&str) -> String>,
pub skip: Option<fn(&str) -> bool>,
}
pub fn parse_anchor_list(html: &str, config: &AnchorConfig) -> Vec<SearchResult> {
let mut results = Vec::new();
let mut seen = HashSet::new();
for caps in config.item_regex.captures_iter(html) {
if results.len() >= config.limit {
break;
}
let raw_url = match caps.get(config.url_group) {
Some(m) => m.as_str(),
None => continue,
};
let url = match config.url_transform {
Some(transform) => transform(raw_url),
None => raw_url.to_string(),
};
if url.is_empty() || seen.contains(&url) {
continue;
}
if let Some(skip) = config.skip {
if skip(&url) {
continue;
}
}
seen.insert(url.clone());
let title = caps
.get(config.title_group)
.map(|m| clean_text(m.as_str()))
.filter(|t| !t.is_empty())
.unwrap_or_else(|| "Untitled".to_string());
let snippet = config
.snippet_group
.and_then(|g| caps.get(g))
.map(|m| clean_text(m.as_str()))
.unwrap_or_default();
results.push(SearchResult {
title,
url,
snippet,
source: config.source.to_string(),
rank: results.len() + 1,
score: None,
sources: None,
});
}
results
}