diff options
Diffstat (limited to 'src/raeapi.rs')
| -rw-r--r-- | src/raeapi.rs | 441 |
1 files changed, 441 insertions, 0 deletions
diff --git a/src/raeapi.rs b/src/raeapi.rs new file mode 100644 index 0000000..0ebc6aa --- /dev/null +++ b/src/raeapi.rs @@ -0,0 +1,441 @@ +//! 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<Data>, +} + +#[derive(Deserialize)] +struct Data { + word: String, + #[serde(default)] + meanings: Vec<Meaning>, +} + +#[derive(Deserialize)] +struct Meaning { + origin: Option<Origin>, + #[serde(default)] + senses: Vec<ApiSense>, +} + +#[derive(Deserialize)] +struct Origin { + raw: Option<String>, +} + +#[derive(Deserialize)] +struct ApiSense { + /// The sense as the dictionary prints it, marks included: + /// `"1. intr. Emitir palabras."` + raw: Option<String>, + meaning_number: Option<u32>, + description: Option<String>, + category: Option<String>, + gender: Option<String>, + usage: Option<String>, + #[serde(default)] + examples: Vec<String>, + synonyms: Option<Vec<String>>, + antonyms: Option<Vec<String>>, +} + +/// Looks up a word. `Ok(None)` if the API says it does not exist. +pub fn lookup(http: &Http, word: &str) -> Result<Option<Entry>> { + 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<String> { + single_word(http, &format!("{BASE}/daily")) +} + +/// A random word from the dictionary. +pub fn random_word(http: &Http) -> Result<String> { + single_word(http, &format!("{BASE}/random")) +} + +fn single_word(http: &Http, url: &str) -> Result<String> { + #[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<String> { + 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<Abbr> { + let mut out: Vec<Abbr> = 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<Abbr> { + 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")]), " "), ""); + } +} |