phrona 0.1.0

High-performance metasearch engine library for Rust and Python
Documentation
use scraper::{ElementRef, Html, Selector};

/// Collapse all whitespace runs into single spaces and trim.
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()
}

/// Join the text of all matches of `selector` with `sep`.
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)
}

/// First element whose own text content is non-empty.
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())
}

// ---------------------------------------------------------------------------
// Redirect unwrapping
// ---------------------------------------------------------------------------

/// `https://www.google.com/url?q=<url>&...`
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()
}

/// `https://www.bing.com/ck/a?u=a1<base64url>` (a1-prefixed payload).
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()
}

/// `//duckduckgo.com/l/?uddg=<urlencoded>`
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()
}

/// Yahoo `/RU=<enc>/RK=2/RS=...` redirects.
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()
}

/// Generic redirect wrapper: `http://example.com/?url=...` variants.
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)
}

// ---------------------------------------------------------------------------
// URL helpers
// ---------------------------------------------------------------------------

pub fn host_of(url: &str) -> Option<String> {
    url::Url::parse(url)
        .ok()
        .map(|u| u.host_str().unwrap_or("").to_string())
}

/// Append a query parameter to a URL 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()
}

/// application/x-www-form-urlencoded (spaces as `+`).
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
}

/// Text snippet around the first occurrence of `needle`.
pub fn excerpt(text: &str, needle: &str, radius: usize) -> String {
    let needle = needle.to_lowercase();
    if needle.is_empty() {
        return truncate(text, radius * 2);
    }
    // Find the needle on the lowercased copy, then map the byte offset back
    // to a char boundary in the original (to_lowercase may change lengths).
    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;
        }
        // one text char expands to `n` lowercase chars; map their combined
        // byte length in `lower` back to byte offsets in `text`
        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("..."));
        // needle not found: plain truncation to 2*radius
        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() {
        // lowercase of "İ" is two chars ("i" + combining dot), which used to
        // shift byte offsets and panic on non-char-boundary slicing
        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...");
        // max below the ellipsis budget yields just the ellipsis
        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/"
        );
        // non-wrapped urls pass through untouched
        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");
    }
}