Skip to main content

phemios_core/
encyclopedia.rs

1//! Explicit Wikipedia text retrieval, independent of Aède authority and offline navigation.
2use serde_json::{Value, json};
3use std::time::{Duration, SystemTime, UNIX_EPOCH};
4use ureq::unversioned::transport::DefaultConnector;
5
6pub mod biography_cache;
7pub(crate) mod resources;
8mod translation;
9
10const MAX_ID: u64 = 9_007_199_254_740_991;
11const RESPONSE_LIMIT: u64 = 1024 * 1024;
12fn language(value: &str) -> Result<&str, String> {
13    match value {
14        "fr" | "en" => Ok(value),
15        _ => Err("Langue Wikipédia non prise en charge.".into()),
16    }
17}
18fn page_id(value: u64) -> Result<u64, String> {
19    if value == 0 || value > MAX_ID {
20        return Err("Identifiant Wikipédia invalide.".into());
21    }
22    Ok(value)
23}
24fn query(value: &str) -> Result<&str, String> {
25    let value = value.trim();
26    if value.is_empty() || value.chars().count() > 200 || value.chars().any(char::is_control) {
27        return Err("Saisissez une recherche de 1 à 200 caractères.".into());
28    }
29    Ok(value)
30}
31fn title(page: &Value) -> Result<&str, String> {
32    page.get("title")
33        .and_then(Value::as_str)
34        .filter(|s| !s.trim().is_empty() && s.len() <= 2048 && !s.chars().any(char::is_control))
35        .ok_or_else(|| "Réponse Wikipédia invalide.".into())
36}
37fn checked_response(value: &Value) -> Result<&Value, String> {
38    if value.get("error").is_some() {
39        return Err("Wikipédia a refusé la requête. Réessayez plus tard.".into());
40    }
41    value
42        .get("query")
43        .ok_or_else(|| "Réponse Wikipédia invalide.".into())
44}
45fn search_result(value: &Value, lang: &str) -> Result<Value, String> {
46    let rows = checked_response(value)?["search"]
47        .as_array()
48        .filter(|rows| rows.len() <= 20)
49        .ok_or("Réponse Wikipédia invalide.")?;
50    let rows = rows.iter().map(|row| {
51        if row["ns"].as_u64() != Some(0) {
52            return Err("Réponse Wikipédia invalide.".into());
53        }
54        Ok(json!({"pageId": page_id(row["pageid"].as_u64().ok_or("Réponse Wikipédia invalide.")?)?,
55            "title": title(row)?}))
56    }).collect::<Result<Vec<Value>, String>>()?;
57    let mut ids = std::collections::HashSet::new();
58    if rows.iter().any(|row| !ids.insert(row["pageId"].as_u64())) {
59        return Err("Réponse Wikipédia invalide.".into());
60    }
61    Ok(json!({"language":lang,"rows":rows}))
62}
63fn article_result(value: &Value, lang: &str, id: u64, retrieved_at: u64) -> Result<Value, String> {
64    let rows = checked_response(value)?["pages"]
65        .as_array()
66        .ok_or("Réponse Wikipédia invalide.")?;
67    if rows.len() != 1 {
68        return Err("Article Wikipédia indisponible.".into());
69    }
70    let row = &rows[0];
71    if row["pageid"].as_u64() != Some(id)
72        || row["ns"].as_u64() != Some(0)
73        || row.get("missing").is_some()
74        || row.get("invalid").is_some()
75    {
76        return Err("Article Wikipédia indisponible.".into());
77    }
78    let text = row["extract"]
79        .as_str()
80        .filter(|text| !text.trim().is_empty() && text.len() <= 512 * 1024)
81        .ok_or("Texte Wikipédia indisponible ou trop volumineux.")?;
82    let revision = page_id(
83        row["lastrevid"]
84            .as_u64()
85            .ok_or("Réponse Wikipédia invalide.")?,
86    )?;
87    let mut article = json!({"language":lang,"pageId":id,"revision":revision,"title":title(row)?,
88        "text":text,"retrievedAt":retrieved_at});
89    if let Some(links) = row.get("externalLinks") {
90        resources::validate(links)?;
91        article["externalLinks"] = links.clone();
92    }
93    Ok(article)
94}
95/// A fixed-host, public-only client; no arbitrary URL, cookies, credentials or redirect is accepted.
96pub struct Client {
97    agent: ureq::Agent,
98}
99impl Default for Client {
100    fn default() -> Self {
101        let config = ureq::Agent::config_builder()
102            .timeout_global(Some(Duration::from_secs(15)))
103            .max_redirects(0)
104            .proxy(None)
105            .build();
106        Self {
107            agent: ureq::Agent::with_parts(
108                config,
109                DefaultConnector::default(),
110                crate::podcast::PublicResolver,
111            ),
112        }
113    }
114}
115impl Client {
116    fn request(&self, lang: &str, params: &[(&str, &str)]) -> Result<Value, String> {
117        self.request_action(lang, "query", params, RESPONSE_LIMIT)
118    }
119    fn request_action(
120        &self,
121        lang: &str,
122        action: &str,
123        params: &[(&str, &str)],
124        limit: u64,
125    ) -> Result<Value, String> {
126        let host = language(lang)?;
127        let mut request = self
128            .agent
129            .get(format!("https://{host}.wikipedia.org/w/api.php"))
130            .header(
131                "User-Agent",
132                concat!(
133                    "Phemios/",
134                    env!("CARGO_PKG_VERSION"),
135                    " (https://craft-and-code.github.io/aede/; music encyclopedia)"
136                ),
137            )
138            .header("Accept", "application/json")
139            .query("action", action)
140            .query("format", "json")
141            .query("formatversion", "2")
142            .query("maxlag", "5");
143        for (key, value) in params {
144            request = request.query(*key, *value);
145        }
146        let mut response = request.call().map_err(
147            |_| "Wikipédia indisponible. Vérifiez votre connexion ou réessayez plus tard.",
148        )?;
149        let bytes = response
150            .body_mut()
151            .with_config()
152            .limit(limit)
153            .read_to_vec()
154            .map_err(|_| "Réponse Wikipédia trop grande ou interrompue.")?;
155        serde_json::from_slice(&bytes).map_err(|_| "Réponse Wikipédia invalide.".into())
156    }
157    /// Explicit full-text search returns at most twenty main-namespace titles without remote HTML.
158    pub fn search(&self, lang: &str, term: &str) -> Result<Value, String> {
159        let term = query(term)?;
160        search_result(
161            &self.request(
162                lang,
163                &[
164                    ("list", "search"),
165                    ("srsearch", term),
166                    ("srnamespace", "0"),
167                    ("srlimit", "20"),
168                    ("srprop", ""),
169                ],
170            )?,
171            lang,
172        )
173    }
174    /// Read a bounded plain-text extract, retaining the revision reported by the same API response.
175    pub fn article(&self, lang: &str, id: u64) -> Result<Value, String> {
176        let id = page_id(id)?;
177        let value = self.request_article(
178            lang,
179            &[
180                ("pageids", &id.to_string()),
181                ("prop", "extracts|info"),
182                ("explaintext", "1"),
183                ("exsectionformat", "wiki"),
184            ],
185        )?;
186        let at = SystemTime::now()
187            .duration_since(UNIX_EPOCH)
188            .map_err(|_| "Horloge système invalide.")?
189            .as_millis();
190        article_result(
191            &value,
192            lang,
193            id,
194            u64::try_from(at).map_err(|_| "Horloge système invalide.")?,
195        )
196    }
197}
198#[cfg(test)]
199#[path = "encyclopedia_tests.rs"]
200mod tests;