//! Reader for the Diccionario de la lengua española (dle.rae.es). //! //! Each word page is laid out like this: //! //! ```text //!
//!

hablar

//!
Del lat. fabulāri.
← etymology //!
  1. …
  2. …
← senses //!

ni hablar

    …
← set phrases //! ``` //! //! Inside each sense, synonyms live in a nested list, so cutting uses //! [`html::balanced`] instead of looking for the next ``. use crate::html::{attr, balanced, collapse_ws, find_open, open_tag, strip_tags}; use crate::model::{Abbr, Entry, Locution, Sense}; const CLASS_DEFS: &str = r#"class="c-definitions""#; /// `true` if the DLE answered with its "word not found" page. It returns 200 /// just like a hit, so the text has to be checked. pub fn is_not_found(html: &str) -> bool { html.contains("no está en el Diccionario") || html.contains("no está en el Diccionario") } /// Extracts the entry from a DLE page. `None` if the word is not there. pub fn parse(html: &str) -> Option { if is_not_found(html) { return None; } let art_start = find_open(html, 0, "article", Some("o-main__article"))?; // The page never closes `
`, so the balanced cut would also take // the footer. Cut where the page footer starts. let article = balanced(html, art_start, "article"); let article = match article.find(" &article[..f], None => article, }; let word = text_of(article, "h1", Some("c-page-header__title"))?; // The conjugation and the final synonym lists are separate sections; // the definitions are everything before them. let conj_at = article.find(r#"id="conjugacion"#); let defs_region = &article[..conj_at.unwrap_or(article.len())]; let etymology = find_open(defs_region, 0, "div", Some("c-text-intro")) .map(|i| strip_tags(balanced(defs_region, i, "div"))) .filter(|s| !s.is_empty()); let mut senses = Vec::new(); let mut locutions = Vec::new(); let mut cursor = 0usize; let mut first = true; while let Some(ol_at) = find_open(defs_region, cursor, "ol", Some(CLASS_DEFS)) { let ol = balanced(defs_region, ol_at, "ol"); let parsed = parse_senses(ol); if first { senses = parsed; first = false; } else if let Some(phrase) = heading_before(defs_region, cursor, ol_at) { locutions.push(Locution { phrase, senses: parsed, }); } cursor = ol_at + ol.len(); } Some(Entry { word, etymology, senses, locutions, // The conjugation is fetched separately, from Wiktionary. conjugation: None, source: "dle".into(), }) } /// The `

` that titles a set phrase, searched between the previous block and this one. fn heading_before(h: &str, from: usize, until: usize) -> Option { let region = &h[from..until]; let mut last = None; let mut i = 0; while let Some(at) = find_open(region, i, "h3", None) { last = Some(strip_tags(balanced(region, at, "h3"))); i = at + 3; } last.filter(|s| !s.is_empty()) } fn text_of(h: &str, tag: &str, class: Option<&str>) -> Option { let needle = class.map(|c| format!("class=\"{c}\"")); let at = find_open(h, 0, tag, needle.as_deref())?; let s = strip_tags(balanced(h, at, tag)); (!s.is_empty()).then_some(s) } fn parse_senses(ol: &str) -> Vec { let mut out = Vec::new(); let mut i = 0; while let Some(at) = find_open(ol, i, "li", None) { let li = balanced(ol, at, "li"); // The dictionary marks the article senses with `class="j"` and the // set-phrase senses with `class="m"`. Skipping the whole element // avoids walking into the synonym list, which is also made of `
  • `. let class = attr(open_tag(ol, at), "class").unwrap_or_default(); if class.starts_with('j') || class.starts_with('m') { if let Some(sense) = parse_sense(li) { out.push(sense); } } i = at + li.len(); } out } fn parse_sense(li: &str) -> Option { // The footer holds synonyms and antonyms; the body, the definition. let footer_at = find_open(li, 0, "div", Some("c-definitions__item-footer")); let body = &li[..footer_at.unwrap_or(li.len())]; let number = element_text(body, "span", "n_acep") .map(|s| s.trim().trim_end_matches('.').to_string()) .filter(|s| !s.is_empty()); let examples = all_element_texts(body, "span", "h"); let grammar = abbreviations(body); // The text is the body minus the number and the examples. let mut text_src = body.to_string(); for (tag, class) in [("span", "n_acep"), ("span", "h")] { text_src = remove_elements(&text_src, tag, class); } let text = collapse_ws(&strip_tags(&text_src)); if text.is_empty() && examples.is_empty() { return None; } let (mut synonyms, mut antonyms) = (Vec::new(), Vec::new()); if let Some(f) = footer_at { let footer = balanced(li, f, "div"); for (label, words) in word_lists(footer) { if label.starts_with("Ant") { antonyms.extend(words); } else { synonyms.extend(words); } } } Some(Sense { number, grammar, text, examples, synonyms, antonyms, }) } /// The `Sin.:` / `Ant.:` lists in a sense footer, with their label. fn word_lists(footer: &str) -> Vec<(String, Vec)> { let mut out = Vec::new(); let mut i = 0; while let Some(at) = find_open(footer, i, "div", Some(r#"class="c-word-list""#)) { let block = balanced(footer, at, "div"); // The label is told apart by the `title` of its ``, which says // "Sinónimos o afines" or "Antónimos u opuestos". let label = find_open(block, 0, "abbr", Some("sin-header-inline")) .and_then(|a| attr(open_tag(block, a), "title")) .or_else(|| element_text(block, "div", "c-word-list__label")) .unwrap_or_default(); let words: Vec = all_element_texts(block, "span", "sin") .into_iter() .map(|w| collapse_ws(w.trim_end_matches([',', '.']))) .filter(|w| !w.is_empty()) .collect(); if !words.is_empty() { out.push((label, words)); } i = at + block.len(); } out } /// The abbreviations of a sense with their expanded form (`f.` → /// «nombre femenino»), which the DLE keeps in the `title` attribute. fn abbreviations(h: &str) -> Vec { let mut out: Vec = Vec::new(); let mut i = 0; while let Some(at) = find_open(h, i, "abbr", None) { let el = balanced(h, at, "abbr"); let short = strip_tags(el); if let Some(long) = attr(open_tag(h, at), "title") { if !short.is_empty() && !out.iter().any(|a| a.short == short) { out.push(Abbr { short, long }); } } i = at + el.len(); } out } /// Text of the first ``. fn element_text(h: &str, tag: &str, class: &str) -> Option { let needle = format!("class=\"{class}\""); let at = find_open(h, 0, tag, Some(&needle))?; Some(strip_tags(balanced(h, at, tag))) } /// Text of every ``, in order. fn all_element_texts(h: &str, tag: &str, class: &str) -> Vec { let needle = format!("class=\"{class}\""); let mut out = Vec::new(); let mut i = 0; while let Some(at) = find_open(h, i, tag, Some(&needle)) { let el = balanced(h, at, tag); let t = strip_tags(el); if !t.is_empty() { out.push(t); } i = at + el.len(); } out } /// Removes the given elements entirely, content included. fn remove_elements(h: &str, tag: &str, class: &str) -> String { let needle = format!("class=\"{class}\""); let mut out = String::with_capacity(h.len()); let mut i = 0; while let Some(at) = find_open(h, i, tag, Some(&needle)) { out.push_str(&h[i..at]); // Leave a space so neighbouring words do not run together. out.push(' '); i = at + balanced(h, at, tag).len(); } out.push_str(&h[i..]); out } #[cfg(test)] mod tests { use super::*; /// A real dle.rae.es page, trimmed to four senses and one set phrase. /// The live path almost never answers (Cloudflare challenges anything /// that is not a browser), so this fixture is what keeps the reader /// verified. const CASA: &str = include_str!("../tests/fixtures/dle-casa.html"); #[test] fn reads_headword_and_etymology() { let e = parse(CASA).expect("should parse the page"); assert_eq!(e.word, "casa"); assert_eq!(e.etymology.as_deref(), Some("Del lat. casa 'choza'.")); assert_eq!(e.source, "dle"); } #[test] fn reads_senses_with_marks_examples_and_synonyms() { let e = parse(CASA).unwrap(); assert_eq!(e.senses.len(), 4, "senses: {}", e.senses.len()); let first = &e.senses[0]; assert_eq!(first.number.as_deref(), Some("1")); // The text keeps the grammar mark and the final dot, as the // dictionary prints it. assert_eq!(first.text, "f. Edificio para habitar."); assert_eq!(first.examples, ["Una casa de ocho plantas."]); assert!(first.synonyms.contains(&"vivienda".to_string())); assert!(first.synonyms.contains(&"casalicio".to_string())); // The example must not leak into the definition. assert!(!first.text.contains("ocho plantas")); // Nor the synonyms. assert!(!first.text.contains("vivienda")); } #[test] fn expands_abbreviations_from_the_title_attribute() { let e = parse(CASA).unwrap(); let g = &e.senses[0].grammar; assert!( g.iter() .any(|a| a.short == "f." && a.long == "nombre femenino"), "abbreviations: {g:?}" ); } #[test] fn reads_locutions() { let e = parse(CASA).unwrap(); assert_eq!(e.locutions.len(), 1, "set phrases: {:?}", e.locutions); assert!(!e.locutions[0].phrase.is_empty()); assert!(!e.locutions[0].senses.is_empty()); } #[test] fn detects_the_not_found_page() { assert!(is_not_found( "

    La palabra «asdfgh» no está en el Diccionario

    " )); assert!(!is_not_found(CASA)); assert!(parse("

    no está en el Diccionario

    ").is_none()); } }