use anyhow::Result;
use regex::Regex;
use scraper::{Html, Selector};
pub struct Etymology {
pub word: String,
pub label: String,
pub etymology: String,
}
impl Etymology {
pub fn new(word: &str) -> Result<Self> {
let results_html = query_etym_online(word)?;
let etymology_html = Etymology::extract_etymology_html(&results_html)?;
let etymology = Etymology::beautify(&etymology_html)?;
let label = Etymology::extract_word_name(&results_html)?;
Ok(Etymology {
word: word.to_owned(),
label,
etymology,
})
}
pub fn beautify(etym_html: &str) -> Result<String> {
let re_italics = Regex::new(r#"<span class="\w+ notranslate">(?P<word>[^<]+)</span>"#)?;
let e: String = re_italics
.replace_all(etym_html, "\x1b[0;3m${word}\x1b[23m")
.to_string();
let html = Html::parse_fragment(&e);
let sel = Selector::parse("div")
.map_err(|_| anyhow::anyhow!("Failed to find HTML div for beautification"))?;
Ok(html.select(&sel).next().unwrap().text().collect::<String>())
}
pub fn extract_etymology_html(raw_html: &str) -> Result<String> {
let re_word_entry = Regex::new(
r#"\\"word\\":\\"[^\\]+\\",\\"canonical_word\\":\\"[^\\]+\\",\\"type\\":\d+,\\"property\\":\\"[^\\]*\\",\\"etymology\\":\\"(.*?)\\",\\"thumbnail\\""#
)?;
if let Some(caps) = re_word_entry.captures(raw_html) {
let etym_value = caps.get(1).unwrap().as_str();
if etym_value.starts_with('$') {
let ref_id = &etym_value[1..]; let ref_pattern = format!(r#"{}:[^,]+,"#, regex::escape(ref_id));
let re_ref = Regex::new(&ref_pattern)?;
if let Some(ref_match) = re_ref.find(raw_html) {
let after_ref = &raw_html[ref_match.end()..];
let re_next_push = Regex::new(r#"self\.__next_f\.push\(\[1,"([^"]*(?:\\.[^"]*)*)"\]\)"#)?;
if let Some(next_caps) = re_next_push.captures(after_ref) {
let etym_html = next_caps.get(1).unwrap().as_str();
let decoded = etym_html
.replace(r"\u003c", "<")
.replace(r"\u003e", ">")
.replace(r"\n", "\n")
.replace(r#"\""#, "\"");
return Ok(format!("<div>{}</div>", decoded));
}
}
} else if etym_value.contains(r"\u003c") || etym_value.contains(r"u003c") {
let decoded = etym_value
.replace(r"\u003c", "<")
.replace(r"\u003e", ">")
.replace(r"u003c", "<")
.replace(r"u003e", ">")
.replace(r"\n", "\n")
.replace(r#"\\""#, "\"") .replace(r#"\""#, "\""); return Ok(format!("<div>{}</div>", decoded));
}
}
let d = Html::parse_document(raw_html);
let section_selector = Selector::parse("section")
.map_err(|_| anyhow::anyhow!("Failed to parse HTML section for entry"))?;
for x in d.select(§ion_selector) {
if let Some(y) = x.value().attr("class") {
if y.starts_with("word__def") {
let etym_html = format!("<div>{}</div>", x.inner_html());
return Ok(etym_html);
}
}
}
Ok(raw_html.to_string())
}
pub fn extract_word_name(raw_html: &str) -> Result<String> {
let re_word = Regex::new(r#"\\"word\\":\\"([^\\]+)\\",\\"canonical_word\\":\\"[^\\]+\\",\\"type\\":\d+,\\"property\\":\\"([^\\]*)\\""#)?;
if let Some(caps) = re_word.captures(raw_html) {
let word = caps.get(1).unwrap().as_str();
let property = caps.get(2).unwrap().as_str();
if property.is_empty() {
return Ok(word.to_string());
} else {
return Ok(format!("{} {}", word, property));
}
}
let d = Html::parse_document(raw_html);
let section_selector = Selector::parse("span[id^='etymonline_v_']")
.map_err(|_| anyhow::anyhow!("Failed to parse 'span' element for word entry"))?;
if let Some(x) = d.select(§ion_selector).next() {
let word_name = x.text().collect::<String>();
return Ok(word_name);
}
anyhow::bail!("Failed to find word name within HTML")
}
}
fn query_etym_online(word: &str) -> Result<String> {
let url = format!("https://www.etymonline.com/search?q={word}");
ureq::get(&url)
.call()?
.into_string()
.map_err(|_| anyhow::anyhow!("Failed to query EtymOnline; network error?"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_html() {
let raw_html = include_str!("../tests/fixture-viking.html");
assert!(raw_html.contains("entries found"));
assert!(raw_html.contains(r#"\"word\":\"Viking\""#));
let document = Html::parse_document(raw_html);
let selector = Selector::parse("body").unwrap();
let body = document.select(&selector).next().unwrap();
assert!(body.value().name() == "body");
}
#[test]
fn html_markup_removed_from_etym() {
let raw_html = include_str!("../tests/fixture-viking.html");
let etym_html = Etymology::extract_etymology_html(&raw_html).unwrap();
assert!(etym_html.contains("Scandinavian pirate"));
assert!(etym_html.contains("vikingr"));
let label = Etymology::extract_word_name(&raw_html).unwrap();
assert_eq!(label, "Viking (n.)");
let etymology = Etymology::beautify(&etym_html).unwrap();
assert!(etymology.contains("Scandinavian pirate"));
assert!(etymology.contains("vikingr"));
assert!(!etymology.contains("<span class=\"foreign notranslate\">"));
assert!(!etymology.contains("<p>"));
}
#[test]
fn scrimshaw_inline_etymology() {
let raw_html = include_str!("../tests/fixture-scrimshaw.html");
let etym_html = Etymology::extract_etymology_html(&raw_html).unwrap();
assert!(etym_html.contains("shell or piece of ivory"));
assert!(etym_html.contains("scrimshon"));
let label = Etymology::extract_word_name(&raw_html).unwrap();
assert_eq!(label, "scrimshaw (n.)");
let etymology = Etymology::beautify(&etym_html).unwrap();
assert!(etymology.contains("shell or piece of ivory"));
assert!(etymology.contains("scrimshon"));
assert!(!etymology.contains("localStorage"));
assert!(!etymology.contains("Log in"));
assert!(!etymology.contains("Remove Ads"));
assert!(!etymology.contains("<span class=\"foreign notranslate\">"));
assert!(!etymology.contains("<p>"));
}
}