akinator_rs/
lib.rs

1//! A simple wrapper crate around the Akinator API
2
3use std::time::{SystemTime, UNIX_EPOCH};
4
5use lazy_static::lazy_static;
6use regex::{Regex, RegexBuilder};
7use reqwest::{
8    Client,
9    header::{
10        HeaderMap, HeaderName, HeaderValue, USER_AGENT,
11    },
12};
13
14use crate::{
15    enums::{Theme, Answer, Language},
16    error::{
17        Result,
18        Error,
19        UpdateInfoError,
20    },
21};
22
23pub mod models;
24pub mod error;
25pub mod enums;
26
27
28lazy_static! {
29    static ref HEADERS: HeaderMap<HeaderValue> = {
30        let mut headers = HeaderMap::new();
31
32        headers.insert(
33            USER_AGENT,
34            HeaderValue::from_static(
35                "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) snap Chromium/81.0.4044.92 Chrome/81.0.4044.92 Safari/537.36"
36            ),
37        );
38        headers.insert(
39            HeaderName::from_static("x-requested-with"),
40            HeaderValue::from_static(
41                "XMLHttpRequest"
42            ),
43        );
44        headers
45    };
46}
47
48/// simple macro for retrieving an `Option` field's value
49/// to avoid repetition as this is frequently used
50macro_rules! get_field {
51    ( $field:expr ) => {
52        $field.as_ref()
53            .ok_or(Error::NoDataFound)?
54            .to_string()
55    }
56}
57
58
59/// Represents an akinator game
60#[derive(Debug, Clone)]
61pub struct Akinator {
62    /// The language for the akinator session
63    pub language: Language,
64    /// The theme for the akinator session
65    ///
66    /// One of 'Characters', 'Animals', or 'Objects'
67    pub theme: Theme,
68    /// indicates whether or not to filter out NSFW questions and content
69    pub child_mode: bool,
70
71    /// The reqwest client used for this akinator session
72    http_client: Client,
73    /// The POSIX timestamp the game session was started
74    /// used for keeping track of sessions
75    timestamp: u64,
76    /// the base URI to use when making requests
77    /// usually: https://{language}.akinator.com/
78    uri: String,
79    /// The unique identifier for the akinator session
80    uid: Option<String>,
81    /// the websocket url (server) used for the game
82    ws_url: Option<String>,
83    /// a (0 - 100) number representing the game's session
84    session: Option<usize>,
85    /// An IP address encoded in Base64, for authentication purposes
86    frontaddr: Option<String>,
87    /// A 9 - 10ish digit number that represents the game's signature
88    signature: Option<usize>,
89    question_filter: Option<String>,
90
91    /// returns the current question to answer
92    pub current_question: Option<String>,
93    /// returns the progress of the akinator
94    /// a float out of 100.0
95    pub progression: f32,
96    /// returns the a counter of questions asked and answered
97    /// starts at 0
98    pub step: usize,
99
100    /// returns the akinator's best guess
101    ///
102    /// Only will be set when [`Self::win`] has been called
103    pub first_guess: Option<models::Guess>,
104    /// a vec containing all the possible guesses by the akinator
105    ///
106    /// Only will be set when [`Self::win`] has been called
107    pub guesses: Vec<models::Guess>,
108}
109
110impl Akinator {
111    /// Creates a new [`Akinator`] instance
112    /// with fields filled with default values
113    ///
114    /// # Errors
115    /// If failed to create HTTP [`reqwest`] client
116    pub fn new() -> Result<Self> {
117        Ok(Self {
118            language: Language::default(),
119            theme: Theme::default(),
120            child_mode: false,
121
122            http_client: Client::builder()
123                .danger_accept_invalid_certs(true)
124                .build()?,
125            timestamp: 0,
126            uri: "https://en.akinator.com".to_string(),
127            uid: None,
128            ws_url: None,
129            session: None,
130            frontaddr: None,
131            signature: None,
132            question_filter: None,
133
134            current_question: None,
135            progression: 0.0,
136            step: 0,
137
138            first_guess: None,
139            guesses: Vec::new(),
140        })
141    }
142
143    /// builder method to set the [`Self.theme`] for the akinator game
144    #[must_use]
145    pub const fn with_theme(mut self, theme: Theme) -> Self {
146        self.theme = theme;
147        self
148    }
149
150    /// builder method to set the [`Self.language`] for the akinator game
151    #[must_use]
152    pub const fn with_language(mut self, language: Language) -> Self {
153        self.language = language;
154        self
155    }
156
157    /// builder function to turn on [`Self.child_mode`]
158    #[must_use]
159    pub const fn with_child_mode(mut self) -> Self {
160        self.child_mode = true;
161        self
162    }
163
164    /// Internal method to handle an error response from the akinator API
165    /// and return an appropriate Err value
166    #[must_use]
167    #[allow(clippy::needless_pass_by_value)]
168    fn handle_error_response(completion: String) -> Error {
169        match completion.to_uppercase().as_str() {
170            "KO - SERVER DOWN" => Error::ServersDown,
171            "KO - TECHNICAL ERROR" => Error::TechnicalError,
172            "KO - TIMEOUT" => Error::TimeoutError,
173            "KO - ELEM LIST IS EMPTY" | "WARN - NO QUESTION" => Error::NoMoreQuestions,
174            _ => Error::ConnectionError,
175        }
176    }
177
178    /// internal method used to parse and find the [`Self.ws_url`] for this game
179    async fn find_server(&self) -> Result<String> {
180        lazy_static! {
181            static ref DATA_REGEX: Regex = RegexBuilder::new(
182                r#"\[\{"translated_theme_name":".*","urlWs":"https:\\/\\/srv[0-9]+\.akinator\.com:[0-9]+\\/ws","subject_id":"[0-9]+"\}\]"#
183            )
184                .case_insensitive(true)
185                .multi_line(true)
186                .build()
187                .unwrap();
188        }
189
190        let html = self.http_client.get(&self.uri)
191            .send()
192            .await?
193            .text()
194            .await?;
195
196        let id = (self.theme as usize)
197            .to_string();
198
199        if let Some(mat) = DATA_REGEX.find(html.as_str()) {
200            let json: Vec<models::ServerData> =
201                serde_json::from_str(mat.as_str())?;
202
203            let mat = json
204                .into_iter()
205                .find(|entry| entry.subject_id == id)
206                .ok_or(Error::NoDataFound)?;
207
208            Ok(mat.url_ws)
209        } else {
210            Err(Error::NoDataFound)
211        }
212    }
213
214    /// internal method used to parse and find the session uid and frontaddr for the akinator session
215    ///
216    /// Done by parsing the javascript of the site, extracting variable values
217    async fn find_session_info(&self) -> Result<(String, String)> {
218        lazy_static! {
219            static ref VARS_REGEX: Regex =
220                RegexBuilder::new(r"var uid_ext_session = '(.*)';\n.*var frontaddr = '(.*)';")
221                    .case_insensitive(true)
222                    .multi_line(true)
223                    .build()
224                    .unwrap();
225        }
226
227        let html = self.http_client
228            .get("https://en.akinator.com/game")
229            .send()
230            .await?
231            .text()
232            .await?;
233
234        if let Some(mat) = VARS_REGEX.captures(html.as_str()) {
235            let result = (
236                mat.get(1).ok_or(Error::NoDataFound)?
237                    .as_str().to_string(),
238                mat.get(2).ok_or(Error::NoDataFound)?
239                    .as_str().to_string(),
240            );
241
242            Ok(result)
243        } else {
244            Err(Error::NoDataFound)
245        }
246    }
247
248    /// internal method used to parse the response returned from the API
249    ///
250    /// strips the function call wrapped around the json, returning the json string
251    #[must_use]
252    #[allow(clippy::needless_pass_by_value)]
253    fn parse_response(html: String) -> String {
254        lazy_static! {
255            static ref RESPONSE_REGEX: Regex =
256                RegexBuilder::new(r"^jQuery\d+_\d+\(")
257                    .case_insensitive(true)
258                    .multi_line(true)
259                    .build()
260                    .unwrap();
261        }
262
263        RESPONSE_REGEX
264            .replace(html.as_str(), "")
265            .strip_suffix(')')
266            .unwrap_or(html.as_str())
267            .to_string()
268    }
269
270    /// updates the [`Akinator`] fields after each response
271    fn update_move_info(&mut self, json: models::MoveJson) -> Result<(), UpdateInfoError> {
272        let params = json.parameters
273            .ok_or(UpdateInfoError::MissingData)?;
274
275        self.current_question = Some(
276            params.question
277        );
278
279        self.progression = params.progression
280            .parse::<f32>()?;
281
282        self.step = params.step
283            .parse::<usize>()?;
284
285        Ok(())
286    }
287
288    /// similar to [`Self::update_move_info`], but only called once when [`Self::start`] is called
289    fn update_start_info(&mut self, json: &models::StartJson) -> Result<(), UpdateInfoError> {
290        let ident = &json.parameters
291            .as_ref()
292            .ok_or(UpdateInfoError::MissingData)?
293            .identification;
294
295        let step_info = &json.parameters
296            .as_ref()
297            .ok_or(UpdateInfoError::MissingData)?
298            .step_information;
299
300        self.session = Some(
301            ident.session
302                .parse::<usize>()?
303        );
304
305        self.signature = Some(
306            ident.signature
307                .parse::<usize>()?
308        );
309
310        self.current_question = Some(
311            step_info.question.clone()
312        );
313
314        self.progression = step_info.progression
315            .parse::<f32>()?;
316
317        self.step = step_info.step
318            .parse::<usize>()?;
319
320        Ok(())
321    }
322
323    /// Starts the akinator game and returns the first question
324    ///
325    /// # Errors
326    ///
327    /// see [errors](https://docs.rs/akinator-rs/latest/akinator_rs/error/enum.Error.html) docs for more info
328    pub async fn start(&mut self) -> Result<Option<String>> {
329        self.uri = format!("https://{}.akinator.com", self.language);
330        self.ws_url = Some(self.find_server().await?);
331
332        let (uid, frontaddr) = self.find_session_info().await?;
333        self.uid = Some(uid);
334        self.frontaddr = Some(frontaddr);
335
336        self.timestamp = SystemTime::now()
337            .duration_since(UNIX_EPOCH)?
338            .as_secs();
339
340        let soft_constraint =
341            if self.child_mode {
342                "ETAT='EN'"
343            } else {
344                ""
345            }
346            .to_string();
347
348        self.question_filter = Some(
349            if self.child_mode {
350                "cat=1"
351            } else {
352                ""
353            }
354            .to_string()
355        );
356
357        let params = [
358            (
359                "callback",
360                format!("jQuery331023608747682107778_{}", self.timestamp),
361            ),
362            ("urlApiWs", get_field!(self.ws_url)),
363            ("partner", 1.to_string()),
364            ("childMod", self.child_mode.to_string()),
365            ("player", "website-desktop".to_string()),
366            ("uid_ext_session", get_field!(self.uid)),
367            ("frontaddr", get_field!(self.frontaddr)),
368            ("constraint", "ETAT<>'AV'".to_string()),
369            ("soft_constraint", soft_constraint),
370            (
371                "question_filter",
372                get_field!(self.question_filter),
373            ),
374        ];
375
376        let response = self.http_client
377            .get(format!("{}/new_session", &self.uri))
378            .headers(HEADERS.clone())
379            .query(&params)
380            .send()
381            .await?;
382
383        let json_string = Self::parse_response(response.text().await?);
384        let json: models::StartJson =
385            serde_json::from_str(json_string.as_str())?;
386
387        if json.completion.as_str() == "OK" {
388            self.update_start_info(&json)?;
389
390            Ok(self.current_question.clone())
391        } else {
392            Err(Self::handle_error_response(json.completion))
393        }
394    }
395
396    /// answers the akinator's current question which can be retrieved with [`Self.current_question`]
397    ///
398    /// # Errors
399    ///
400    /// see [errors](https://docs.rs/akinator-rs/latest/akinator_rs/error/enum.Error.html) docs for more info
401    pub async fn answer(&mut self, answer: Answer) -> Result<Option<String>> {
402        let params = [
403            (
404                "callback",
405                format!("jQuery331023608747682107778_{}", self.timestamp),
406            ),
407            ("urlApiWs", get_field!(self.ws_url)),
408            ("childMod", self.child_mode.to_string()),
409            ("session", get_field!(self.session)),
410            ("signature", get_field!(self.signature)),
411            ("frontaddr", get_field!(self.frontaddr)),
412            ("step", self.step.to_string()),
413            ("answer", (answer as u8).to_string()),
414            (
415                "question_filter",
416                get_field!(self.question_filter),
417            ),
418        ];
419
420        let response = self.http_client
421            .get(format!("{}/answer_api", &self.uri))
422            .headers(HEADERS.clone())
423            .query(&params)
424            .send()
425            .await?
426            .text()
427            .await?;
428
429        let json_string = Self::parse_response(response);
430        let json: models::MoveJson =
431            serde_json::from_str(json_string.as_str())?;
432
433        if json.completion.as_str() == "OK" {
434            self.update_move_info(json)?;
435
436            Ok(self.current_question.clone())
437        } else {
438            Err(Self::handle_error_response(json.completion))
439        }
440    }
441
442    /// tells the akinator to end the game and make it's guess
443    /// and returns its best guess, which also can be retrieved with [`Self.first_guess`]
444    ///
445    /// # Errors
446    ///
447    /// see [errors](https://docs.rs/akinator-rs/latest/akinator_rs/error/enum.Error.html) docs for more info
448    pub async fn win(&mut self) -> Result<Option<models::Guess>> {
449        let params = [
450            (
451                "callback",
452                format!("jQuery331023608747682107778_{}", self.timestamp),
453            ),
454            ("childMod", self.child_mode.to_string()),
455            ("session", get_field!(self.session)),
456            ("signature", get_field!(self.signature)),
457            ("step", self.step.to_string()),
458        ];
459
460        let response = self.http_client
461            .get(format!("{}/list", get_field!(self.ws_url)))
462            .headers(HEADERS.clone())
463            .query(&params)
464            .send()
465            .await?
466            .text()
467            .await?;
468
469        let json_string = Self::parse_response(response);
470        let json: models::WinJson =
471            serde_json::from_str(json_string.as_str())?;
472
473        if json.completion.as_str() == "OK" {
474            let elements = json.parameters
475                .ok_or(UpdateInfoError::MissingData)?
476                .elements;
477
478            self.guesses = elements
479                .into_iter()
480                .map(|e| e.element)
481                .collect::<Vec<models::Guess>>();
482
483            self.first_guess = self.guesses
484                .first()
485                .cloned();
486
487            Ok(self.first_guess.clone())
488        } else {
489            Err(Self::handle_error_response(json.completion))
490        }
491    }
492
493    /// Goes back 1 question and returns the current question
494    /// Returns an Err value with [`Error::CantGoBackAnyFurther`] if we are already on question 0
495    ///
496    /// # Errors
497    ///
498    /// see [errors](https://docs.rs/akinator-rs/latest/akinator_rs/error/enum.Error.html) docs for more info
499    pub async fn back(&mut self) -> Result<Option<String>> {
500        if self.step == 0 {
501            return Err(Error::CantGoBackAnyFurther);
502        }
503
504        let params = [
505            (
506                "callback",
507                format!("jQuery331023608747682107778_{}", self.timestamp),
508            ),
509            ("childMod", self.child_mode.to_string()),
510            ("session", get_field!(self.session)),
511            ("signature", get_field!(self.signature)),
512            ("step", self.step.to_string()),
513            ("answer", "-1".to_string()),
514            (
515                "question_filter",
516                get_field!(self.question_filter)
517            ),
518        ];
519
520        let response = self.http_client
521            .get(format!("{}/cancel_answer", get_field!(self.ws_url)))
522            .headers(HEADERS.clone())
523            .query(&params)
524            .send()
525            .await?
526            .text()
527            .await?;
528
529        let json_string = Self::parse_response(response);
530        let json: models::MoveJson =
531            serde_json::from_str(json_string.as_str())?;
532
533        if json.completion.as_str() == "OK" {
534            self.update_move_info(json)?;
535
536            Ok(self.current_question.clone())
537        } else {
538            Err(Self::handle_error_response(json.completion))
539        }
540    }
541}