use scraper::{ElementRef, Html, Selector};
pub fn collapse(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut space = false;
for c in s.chars() {
if c.is_whitespace() {
space = true;
} else {
if space && !out.is_empty() {
out.push(' ');
}
space = false;
out.push(c);
}
}
out.trim().to_string()
}
pub fn text_of(el: &ElementRef) -> String {
collapse(&el.text().collect::<String>())
}
pub fn attr(el: &ElementRef, selector: &str, name: &str) -> Option<String> {
let sel = Selector::parse(selector).ok()?;
el.select(&sel)
.next()?
.value()
.attr(name)
.map(|s| s.to_string())
}
pub fn select_text(el: &ElementRef, selector: &str) -> Option<String> {
let sel = Selector::parse(selector).ok()?;
el.select(&sel)
.next()
.map(|e| text_of(&e))
.filter(|t| !t.is_empty())
}
pub fn select_texts(el: &ElementRef, selector: &str) -> Vec<String> {
let Ok(sel) = Selector::parse(selector) else {
return Vec::new();
};
el.select(&sel).map(|e| text_of(&e)).collect()
}
pub fn select_text_joined(el: &ElementRef, selector: &str, sep: &str) -> String {
let parts: Vec<String> = select_texts(el, selector)
.into_iter()
.filter(|t| !t.is_empty())
.collect();
parts.join(sep)
}
pub fn select_first_nonempty(el: &ElementRef, selector: &str) -> Option<String> {
let sel = Selector::parse(selector).ok()?;
for node in el.select(&sel) {
let t = text_of(&node);
if !t.is_empty() {
return Some(t);
}
}
None
}
pub fn parse_html(html: &str) -> Html {
Html::parse_document(html)
}
pub fn doc_text(doc: &Html, selector: &str) -> Option<String> {
let sel = Selector::parse(selector).ok()?;
doc.select(&sel)
.next()
.map(|e| text_of(&e))
.filter(|t| !t.is_empty())
}
pub fn doc_attr(doc: &Html, selector: &str, name: &str) -> Option<String> {
let sel = Selector::parse(selector).ok()?;
doc.select(&sel)
.next()?
.value()
.attr(name)
.map(|s| s.to_string())
}
pub fn unwrap_google_url(href: &str) -> String {
if href.starts_with("/url?q=")
|| href.starts_with("http://www.google.com/url?q=")
|| href.starts_with("https://www.google.com/url?q=")
{
let q = href.split_once("?q=").map(|(_, r)| r).unwrap_or(href);
let url = q.split('&').next().unwrap_or(q);
return percent_decode(url);
}
href.to_string()
}
pub fn unwrap_bing_url(href: &str) -> String {
if let Some(idx) = href.find("u=a1") {
let enc = &href[idx + 4..];
let enc = enc.split('&').next().unwrap_or(enc);
if let Ok(dec) = decode_b64url(enc) {
return String::from_utf8_lossy(&dec).into_owned();
}
}
href.to_string()
}
pub fn unwrap_ddg_url(href: &str) -> String {
if href.contains("duckduckgo.com/l/") && href.contains("uddg=") {
let q = href.split_once("uddg=").map(|(_, r)| r).unwrap_or(href);
return percent_decode(q.split('&').next().unwrap_or(q));
}
href.to_string()
}
pub fn unwrap_yahoo_url(href: &str) -> String {
if href.contains("/RU=") {
let after = href.split("/RU=").last().unwrap_or(href);
let end = after
.find("/RK=")
.or_else(|| after.find("/RS="))
.unwrap_or(after.len());
return percent_decode(&after[..end]);
}
href.to_string()
}
pub fn unwrap_wrapper_url(href: &str) -> String {
for marker in ["?url=", "&url=", "?u=", "&u=", "?redirect="] {
if let Some(idx) = href.find(marker) {
let val = &href[idx + marker.len()..];
let val = val.split('&').next().unwrap_or(val);
if val.starts_with("http") {
return percent_decode(val);
}
}
}
href.to_string()
}
pub fn percent_decode(s: &str) -> String {
percent_encoding::percent_decode_str(s)
.decode_utf8_lossy()
.into_owned()
}
fn decode_b64url(s: &str) -> Result<Vec<u8>, base64::DecodeError> {
use base64::Engine;
base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(s)
}
pub fn host_of(url: &str) -> Option<String> {
url::Url::parse(url)
.ok()
.map(|u| u.host_str().unwrap_or("").to_string())
}
pub fn with_query<I, K, V>(url: &str, params: I) -> String
where
I: IntoIterator<Item = (K, V)>,
K: AsRef<str>,
V: AsRef<str>,
{
let mut u =
url::Url::parse(url).unwrap_or_else(|_| url::Url::parse("https://invalid").unwrap());
let mut q: Vec<(String, String)> = u
.query_pairs()
.map(|(k, v)| (k.into_owned(), v.into_owned()))
.collect();
for (k, v) in params {
q.push((k.as_ref().to_string(), v.as_ref().to_string()));
}
u.set_query(None);
u.set_query(Some(
&q.iter()
.map(|(k, v)| format!("{}={}", encode_query(k), encode_query(v)))
.collect::<Vec<_>>()
.join("&"),
));
u.to_string()
}
pub fn encode_query(s: &str) -> String {
use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode};
utf8_percent_encode(s, NON_ALPHANUMERIC).to_string()
}
pub fn form_encode<I, K, V>(params: I) -> String
where
I: IntoIterator<Item = (K, V)>,
K: AsRef<str>,
V: AsRef<str>,
{
params
.into_iter()
.map(|(k, v)| format!("{}={}", form_enc(k.as_ref()), form_enc(v.as_ref())))
.collect::<Vec<_>>()
.join("&")
}
fn form_enc(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for b in s.as_bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'*' | b'-' | b'.' | b'_' => {
out.push(*b as char)
}
b' ' => out.push('+'),
_ => out.push_str(&format!("%{:02X}", b)),
}
}
out
}
pub fn excerpt(text: &str, needle: &str, radius: usize) -> String {
let needle = needle.to_lowercase();
if needle.is_empty() {
return truncate(text, radius * 2);
}
let lower = text.to_lowercase();
let Some(pos) = lower.find(&needle) else {
return truncate(text, radius * 2);
};
let mut li = 0usize;
let mut tpos = 0usize;
for c in text.chars() {
if li >= pos {
break;
}
let n = c.to_lowercase().count();
let l = lower[li..]
.chars()
.take(n)
.map(|x| x.len_utf8())
.sum::<usize>();
if li + l > pos {
break;
}
li += l;
tpos += c.len_utf8();
}
let before = text[..tpos].chars().count();
let start_char = before.saturating_sub(radius);
let end_char = (before + needle.chars().count() + radius).min(text.chars().count());
let start = text
.char_indices()
.nth(start_char)
.map(|(i, _)| i)
.unwrap_or(0);
let end = text
.char_indices()
.nth(end_char)
.map(|(i, _)| i)
.unwrap_or(text.len());
let mut out = text[start..end].trim().to_string();
if start > 0 {
out = format!("...{out}");
}
if end < text.len() {
out.push_str("...");
}
out
}
pub fn truncate(text: &str, max: usize) -> String {
if text.chars().count() <= max {
return text.to_string();
}
let keep = max.saturating_sub(3);
let cut: String = text.chars().take(keep).collect();
if keep == 0 { cut } else { format!("{cut}...") }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn collapse_normalizes_whitespace() {
assert_eq!(collapse(" a\n\t b c "), "a b c");
assert_eq!(collapse(""), "");
}
#[test]
fn excerpt_ascii() {
let text = "the quick brown fox jumps over the lazy dog";
let out = excerpt(text, "brown", 6);
assert!(out.contains("brown"), "{out}");
assert!(out.starts_with("..."));
assert!(out.ends_with("..."));
assert_eq!(excerpt(text, "zebra", 5), "the qui...");
assert_eq!(excerpt("short", "x", 10), "short");
assert_eq!(excerpt("abc def", "", 3), "abc...");
}
#[test]
fn excerpt_handles_multibyte_without_panicking() {
let text = "İstanbul Üniversitesi merkez kampüs";
let out = excerpt(text, "üniversitesi", 10);
assert!(out.to_lowercase().contains("üniversitesi"), "{out}");
let text = "日本語のテキストで検索するテスト";
let out = excerpt(text, "検索", 4);
assert!(out.contains("検索"), "{out}");
}
#[test]
fn truncate_respects_char_boundaries() {
assert_eq!(truncate("hello world", 5), "he...");
assert_eq!(truncate("日本語", 2), "");
assert_eq!(truncate("abc", 5), "abc");
assert_eq!(truncate("abc", 1), "");
}
#[test]
fn unwraps_redirect_wrappers() {
assert_eq!(
unwrap_google_url(
"https://www.google.com/url?q=https%3A%2F%2Fexample.com%2Fa%3Fx%3D1&sa=U"
),
"https://example.com/a?x=1"
);
assert_eq!(
unwrap_bing_url("https://www.bing.com/ck/a?u=a1aHR0cHM6Ly9leGFtcGxlLmNvbS8&ntb=1"),
"https://example.com/"
);
assert_eq!(
unwrap_ddg_url("//duckduckgo.com/l/?uddg=https%3A%2F%2Fexample.com&rut=x"),
"https://example.com"
);
assert_eq!(
unwrap_yahoo_url("https://r.search.yahoo.com/RU=https%3A%2F%2Fexample.com/RK=2/RS=zzz"),
"https://example.com"
);
assert_eq!(
unwrap_wrapper_url("https://example.com/?url=https%3A%2F%2Freal.example.com%2F"),
"https://real.example.com/"
);
assert_eq!(
unwrap_google_url("https://example.com/"),
"https://example.com/"
);
}
#[test]
fn host_and_query_helpers() {
assert_eq!(
host_of("https://example.com/a?b=1"),
Some("example.com".into())
);
assert_eq!(host_of("not a url"), None);
assert_eq!(
with_query("https://example.com/", [("q", "a b"), ("x", "y")]),
"https://example.com/?q=a%20b&x=y"
);
assert_eq!(
with_query("https://example.com/?a=1", [("b", "2")]),
"https://example.com/?a=1&b=2"
);
assert_eq!(encode_query("a b&c"), "a%20b%26c");
assert_eq!(form_encode([("q", "a b"), ("x", "y/z")]), "q=a+b&x=y%2Fz");
}
}