//! Main source: rae-api.com, which serves the DLE content as JSON. //! //! It carries less than the DLE page (no conjugation) and allows 10 requests //! per minute, but its format is stable and, unlike dle.rae.es, it answers //! automated clients. use anyhow::{Context, Result}; use serde::Deserialize; use crate::client::{percent_encode, Http}; use crate::model::{Abbr, Entry, Sense}; const BASE: &str = "https://rae-api.com/api"; #[derive(Deserialize)] struct Response { data: Option, } #[derive(Deserialize)] struct Data { word: String, #[serde(default)] meanings: Vec, } #[derive(Deserialize)] struct Meaning { origin: Option, #[serde(default)] senses: Vec, } #[derive(Deserialize)] struct Origin { raw: Option, } #[derive(Deserialize)] struct ApiSense { /// The sense as the dictionary prints it, marks included: /// `"1. intr. Emitir palabras."` raw: Option, meaning_number: Option, description: Option, category: Option, gender: Option, usage: Option, #[serde(default)] examples: Vec, synonyms: Option>, antonyms: Option>, } /// Looks up a word. `Ok(None)` if the API says it does not exist. pub fn lookup(http: &Http, word: &str) -> Result> { let url = format!("{BASE}/words/{}", percent_encode(word)); let (status, body) = http.get(&url, crate::client::CLIENT_UA)?; if status == 404 { return Ok(None); } if status == 429 { anyhow::bail!( "rae-api limita a 10 consultas por minuto y se alcanzó el tope; \ espera un momento (lo ya consultado sigue disponible en la caché)" ); } if status != 200 { anyhow::bail!("rae-api respondió {status}"); } let res: Response = serde_json::from_str(&body).context("rae-api devolvió un JSON inesperado")?; Ok(res.data.map(into_entry)) } /// The word of the day published by the RAE. pub fn word_of_the_day(http: &Http) -> Result { single_word(http, &format!("{BASE}/daily")) } /// A random word from the dictionary. pub fn random_word(http: &Http) -> Result { single_word(http, &format!("{BASE}/random")) } fn single_word(http: &Http, url: &str) -> Result { #[derive(Deserialize)] struct Wrapper { data: Inner, } #[derive(Deserialize)] struct Inner { word: String, } let (status, body) = http.get(url, crate::client::CLIENT_UA)?; if status == 429 { anyhow::bail!("rae-api limita a 10 consultas por minuto y se alcanzó el tope"); } if status != 200 { anyhow::bail!("rae-api respondió {status}"); } let w: Wrapper = serde_json::from_str(&body).context("rae-api devolvió un JSON inesperado")?; Ok(w.data.word) } fn into_entry(d: Data) -> Entry { let etymology = d .meanings .iter() .find_map(|m| m.origin.as_ref().and_then(|o| o.raw.clone())) .filter(|s| !s.trim().is_empty()); let senses = d .meanings .iter() .flat_map(|m| m.senses.iter()) .map(into_sense) .collect(); Entry { word: d.word, etymology, senses, locutions: vec![], conjugation: None, source: "rae-api".into(), } } fn into_sense(s: &ApiSense) -> Sense { // The API labels in English; translate to the DLE abbreviations so the // output looks the same whatever the source. let description = s.description.as_deref().unwrap_or_default().trim(); // The `raw` field holds the sense as printed, with the exact dictionary // marks («intr.», «p. us.»). That beats rebuilding them from the English // labels, which are coarser: they say «verb» where the dictionary tells // transitive from intransitive. if let Some(marks) = s.raw.as_deref().and_then(|r| printed_marks(r, description)) { let grammar = expand_marks(&marks); return Sense { number: s.meaning_number.map(|n| n.to_string()), grammar, text: close_sentence(&format!("{marks} {description}")), examples: s.examples.clone(), synonyms: s.synonyms.clone().unwrap_or_default(), antonyms: s.antonyms.clone().unwrap_or_default(), }; } let mut grammar = Vec::new(); // The dictionary writes «f.», not «s. f.»: the gender mark already says // the word is a noun, so the category is redundant. let gendered = s.gender.as_deref().is_some_and(|g| !g.is_empty()); let category = match s.category.as_deref() { Some("noun") if gendered => None, other => other.map(str::to_string), }; for (value, table) in [ (&category, CATEGORIES), (&s.gender, GENDERS), (&s.usage, USAGES), ] { let Some(v) = value.as_deref().filter(|v| !v.is_empty()) else { continue; }; if let Some((short, long)) = table .iter() .find(|(k, _, _)| *k == v) .map(|(_, s, l)| (*s, *l)) { grammar.push(Abbr { short: short.into(), long: long.into(), }); } } // The API returns the bare definition, without the marks or the final // dot the dictionary prints. Put them back so the output reads the same // whatever the source. let text = printed_form(&grammar, s.description.as_deref().unwrap_or_default()); Sense { number: s.meaning_number.map(|n| n.to_string()), grammar, text, examples: s.examples.clone(), synonyms: s.synonyms.clone().unwrap_or_default(), antonyms: s.antonyms.clone().unwrap_or_default(), } } /// Builds the definition as the dictionary prints it: grammar marks in front /// and a dot at the end. fn printed_form(grammar: &[Abbr], description: &str) -> String { let description = description.trim(); if description.is_empty() { return String::new(); } let marks: Vec<&str> = grammar.iter().map(|a| a.short.as_str()).collect(); let joined = if marks.is_empty() { description.to_string() } else { format!("{} {description}", marks.join(" ")) }; close_sentence(&joined) } /// Adds the final dot unless the definition already ends closed. fn close_sentence(text: &str) -> String { let text = text.trim(); if text.is_empty() { return String::new(); } match text.chars().next_back() { Some(c) if ".!?…".contains(c) => text.to_string(), _ => format!("{text}."), } } /// Cuts the marks out of the printed text: whatever sits between the sense /// number and the start of the definition. fn printed_marks(raw: &str, description: &str) -> Option { if description.is_empty() { return None; } let head = &raw[..raw.find(description)?]; let head = head.trim_start_matches(|c: char| c.is_ascii_digit()); let marks = head.trim_start().strip_prefix('.').unwrap_or(head).trim(); (!marks.is_empty()).then(|| marks.to_string()) } /// Expands dictionary abbreviations. Tries the longest first, because /// «p. us.» starts like a one-letter abbreviation. fn expand_marks(marks: &str) -> Vec { let mut out: Vec = Vec::new(); let mut rest = marks.trim(); while !rest.is_empty() { match ABBREVIATIONS.iter().find(|(s, _)| rest.starts_with(s)) { Some((short, long)) => { if !out.iter().any(|a| a.short == *short) { out.push(Abbr { short: (*short).into(), long: (*long).into(), }); } rest = rest[short.len()..].trim_start(); } // Unknown mark: skip it without breaking the rest. None => match rest.find(' ') { Some(i) => rest = rest[i + 1..].trim_start(), None => break, }, } } out } /// Dictionary abbreviations, longest first so prefix matching never splits /// one in two. const ABBREVIATIONS: &[(&str, &str)] = &[ ("p. us.", "poco usado"), ("interj.", "interjección"), ("prnl.", "verbo pronominal"), ("coloq.", "coloquial"), ("desus.", "desusado"), ("intr.", "verbo intransitivo"), ("prep.", "preposición"), ("pron.", "pronombre"), ("conj.", "conjunción"), ("adj.", "adjetivo"), ("adv.", "adverbio"), ("amb.", "nombre ambiguo"), ("com.", "nombre común"), ("vulg.", "vulgar"), ("cult.", "culto"), ("art.", "artículo"), ("tr.", "verbo transitivo"), ("f.", "nombre femenino"), ("m.", "nombre masculino"), ("U.", "usado"), ]; type Lex = &'static [(&'static str, &'static str, &'static str)]; const CATEGORIES: Lex = &[ ("noun", "s.", "sustantivo"), ("adjective", "adj.", "adjetivo"), ("verb", "v.", "verbo"), ("transitive verb", "tr.", "verbo transitivo"), ("intransitive verb", "intr.", "verbo intransitivo"), ("pronominal verb", "prnl.", "verbo pronominal"), ("adverb", "adv.", "adverbio"), ("interjection", "interj.", "interjección"), ("preposition", "prep.", "preposición"), ("conjunction", "conj.", "conjunción"), ("pronoun", "pron.", "pronombre"), ("article", "art.", "artículo"), ]; const GENDERS: Lex = &[ ("feminine", "f.", "nombre femenino"), ("masculine", "m.", "nombre masculino"), ("ambiguous", "amb.", "nombre ambiguo"), ("common", "com.", "nombre común"), ]; const USAGES: Lex = &[ ("rare", "p. us.", "poco usado"), ("colloquial", "coloq.", "coloquial"), ("vulgar", "vulg.", "vulgar"), ("obsolete", "desus.", "desusado"), ("formal", "cult.", "culto"), ]; #[cfg(test)] mod tests { use super::*; fn abbrs(list: &[(&str, &str)]) -> Vec { list.iter() .map(|(s, l)| Abbr { short: (*s).into(), long: (*l).into(), }) .collect() } #[test] fn restores_marks_and_final_stop() { let g = abbrs(&[("f.", "nombre femenino")]); assert_eq!( printed_form(&g, "Edificio para habitar"), "f. Edificio para habitar." ); } #[test] fn does_not_double_the_final_stop() { assert_eq!(printed_form(&[], "Edificio."), "Edificio."); assert_eq!(printed_form(&[], "¿Qué?"), "¿Qué?"); } #[test] fn closes_a_definition_that_ends_in_a_parenthesis() { assert_eq!(printed_form(&[], "piso (‖ vivienda)"), "piso (‖ vivienda)."); } #[test] fn joins_several_marks() { let g = abbrs(&[("f.", "nombre femenino"), ("p. us.", "poco usado")]); assert_eq!(printed_form(&g, "Casa"), "f. p. us. Casa."); } fn api_sense(category: &str, gender: &str) -> ApiSense { ApiSense { raw: None, meaning_number: Some(1), description: Some("Edificio para habitar".into()), category: Some(category.into()), gender: Some(gender.into()), usage: Some(String::new()), examples: vec![], synonyms: None, antonyms: None, } } #[test] fn a_gender_mark_replaces_the_noun_category() { // The DLE prints «f.», not «s. f.». let s = into_sense(&api_sense("noun", "feminine")); assert_eq!(s.text, "f. Edificio para habitar."); assert_eq!(s.grammar.len(), 1); assert_eq!(s.grammar[0].short, "f."); } #[test] fn a_category_without_gender_is_kept() { let s = into_sense(&api_sense("intransitive verb", "")); assert_eq!(s.text, "intr. Edificio para habitar."); } #[test] fn takes_the_marks_from_the_printed_text() { // The API only says «verb»; the printed text says intransitive. let raw = "1. intr. Emitir palabras.Sin.:comunicar, expresar."; assert_eq!( printed_marks(raw, "Emitir palabras").as_deref(), Some("intr.") ); } #[test] fn printed_marks_handle_several_marks_and_two_digit_numbers() { let raw = "12. m. p. us. Cosa vieja."; assert_eq!( printed_marks(raw, "Cosa vieja").as_deref(), Some("m. p. us.") ); } #[test] fn printed_marks_are_none_when_there_are_none() { assert_eq!( printed_marks("1. Emitir palabras.", "Emitir palabras"), None ); assert_eq!( printed_marks("1. intr. Otra cosa.", "Emitir palabras"), None ); } #[test] fn a_whole_sense_uses_the_printed_marks() { let mut s = api_sense("verb", ""); s.raw = Some("1. intr. Emitir palabras.".into()); s.description = Some("Emitir palabras".into()); let out = into_sense(&s); assert_eq!(out.text, "intr. Emitir palabras."); assert_eq!(out.grammar[0].long, "verbo intransitivo"); } #[test] fn expands_multi_token_abbreviations_first() { let g = expand_marks("m. p. us."); let shorts: Vec<&str> = g.iter().map(|a| a.short.as_str()).collect(); assert_eq!(shorts, ["m.", "p. us."], "must not split «p. us.»"); } #[test] fn unknown_marks_are_skipped_without_losing_the_rest() { let g = expand_marks("zz. f."); assert_eq!(g.len(), 1); assert_eq!(g[0].short, "f."); } #[test] fn empty_description_stays_empty() { assert_eq!(printed_form(&abbrs(&[("f.", "x")]), " "), ""); } }