aboutsummaryrefslogtreecommitdiffstats
path: root/src/raeapi.rs
diff options
context:
space:
mode:
authorElvis Claros Castro <elvis@claros.ar>2026-09-26 19:00:45 -0300
committerElvis Claros Castro <elvis@claros.ar>2026-09-26 19:00:45 -0300
commita946ba81f7a6089713337644053b00b825824a98 (patch)
treedf7c98401b3a879a0283e940afa3fecc9453d9d4 /src/raeapi.rs
downloaddrae-cli-main.tar.gz
drae-cli-main.zip
drae: the RAE Spanish dictionary in the terminalHEADmain
Diffstat (limited to 'src/raeapi.rs')
-rw-r--r--src/raeapi.rs441
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")]), " "), "");
+ }
+}