1use 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}
95pub 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 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 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;