deeplx/skeleton/
translate.rs

1use std::io;
2
3use rand::prelude::*;
4
5use super::data::{DeepLXTranslationResult, Lang, Params, PostData, TextItem, TranslationResponse};
6use super::error::{Error, LangDetectError};
7use super::utils::{get_i_count, get_random_number, get_timestamp};
8
9use reqwest::{
10    Client, Response, StatusCode,
11    header::{
12        ACCEPT, ACCEPT_ENCODING, ACCEPT_LANGUAGE, CONNECTION, CONTENT_TYPE, COOKIE, DNT, HeaderMap,
13        HeaderValue, UPGRADE_INSECURE_REQUESTS, USER_AGENT,
14    },
15};
16#[cfg(not(target_arch = "wasm32"))]
17use reqwest::{Proxy, retry};
18
19/// Configuration settings for the `DeepLX` translation client.
20///
21/// # Examples
22///
23/// ## Using Default Configuration
24///
25/// ```no_run
26/// use deeplx::{Config, DeepLX};
27///
28/// let translator = DeepLX::new(Config::default());
29/// ```
30///
31/// ## Custom Base URL
32///
33/// ```no_run
34/// use deeplx::{Config, DeepLX};
35///
36/// let translator = DeepLX::new(Config {
37///     base_url: "https://custom.deepl.api/jsonrpc".to_string(),
38///     ..Default::default()
39/// });
40/// ```
41///
42/// ## Configuring a Proxy (Not available on wasm32)
43///
44/// ```no_run
45/// use deeplx::{Config, DeepLX};
46///
47/// let translator = DeepLX::new(Config {
48///     #[cfg(not(target_arch = "wasm32"))]
49///     proxy: Some("http://pro.xy".to_string()),
50///     ..Default::default()
51/// });
52/// ```
53pub struct Config {
54    pub base_url: String,
55    #[cfg(not(target_arch = "wasm32"))]
56    pub proxy: Option<String>,
57}
58
59impl Default for Config {
60    fn default() -> Self {
61        Self {
62            base_url: "https://www2.deepl.com/jsonrpc".to_string(),
63            #[cfg(not(target_arch = "wasm32"))]
64            proxy: None,
65        }
66    }
67}
68
69/// The main entry point for interacting with the DeepL translation service.
70///
71/// `DeepLX` provides methods to create a translation client and perform translation
72/// requests. You can optionally specify a proxy (on non‑wasm32 targets),
73/// choose source and target languages, and retrieve alternative translations.
74#[derive(Clone)]
75pub struct DeepLX {
76    base_url: String,
77    #[cfg(not(target_arch = "wasm32"))]
78    proxy: Option<String>,
79    headers: HeaderMap,
80}
81
82impl DeepLX {
83    /// Constructs a new `DeepLX` instance.
84    ///
85    /// # Parameters
86    ///
87    /// * `Config` - Configuration settings for the translation client.
88    ///
89    /// # Panics
90    ///
91    /// This method will panic if the provided proxy string is invalid.
92    ///
93    /// # Examples
94    ///
95    /// ```no_run
96    /// use deeplx::{Config, DeepLX};
97    ///
98    /// let translator = DeepLX::new(Config::default());
99    /// let translator_with_proxy = DeepLX::new(Config {
100    ///     #[cfg(not(target_arch = "wasm32"))]
101    ///     proxy: Some("http://pro.xy".to_string()),
102    ///     ..Default::default()
103    /// });
104    /// ```
105    pub fn new(config: Config) -> Self {
106        Self {
107            base_url: config.base_url,
108            #[cfg(not(target_arch = "wasm32"))]
109            proxy: config.proxy,
110            headers: headers(),
111        }
112    }
113
114    async fn make_request(
115        &self,
116        post_data: &PostData<'_>,
117        deepl_session: Option<&str>,
118    ) -> Result<(StatusCode, Response), Error> {
119        let mut headers = self.headers.clone();
120        if let Some(session) = deepl_session {
121            headers.insert(COOKIE, session.parse().unwrap());
122        }
123
124        let data = serde_json::to_string(&post_data)?;
125
126        let use_colon_spacing = ((post_data.id + 5) % 29 == 0) || ((post_data.id + 3) % 13 == 0);
127        let replacement = if use_colon_spacing {
128            r#""method" : ""#
129        } else {
130            r#""method": ""#
131        };
132
133        let data = data.replacen(r#""method":""#, replacement, 1);
134
135        let builder = Client::builder();
136
137        #[cfg(not(target_arch = "wasm32"))]
138        let builder = match &self.proxy {
139            Some(p) => builder.proxy(Proxy::all(p.clone())?),
140            None => builder,
141        };
142
143        #[cfg(not(target_arch = "wasm32"))]
144        let builder = builder.retry(
145            retry::for_host(self.base_url.clone())
146                .max_retries_per_request(3)
147                .classify_fn(|req| match req.status() {
148                    Some(status) if status == StatusCode::TOO_MANY_REQUESTS => req.retryable(),
149                    _ => req.success(),
150                }),
151        );
152
153        let resp = builder
154            .build()?
155            .post(&self.base_url)
156            .headers(headers)
157            .body(data)
158            .send()
159            .await?;
160
161        Ok((resp.status(), resp))
162    }
163
164    /// Translates the given text from a source language to a target language.
165    ///
166    /// This method automatically handles splitting the text into translation jobs,
167    /// detecting the source language (if set to "auto"), and returning the translated text.
168    ///
169    /// # Parameters
170    ///
171    /// * `source_lang` - The source language code, e.g. `"en"`. Use `"auto"` to let the system detect the language.
172    /// * `target_lang` - The target language code, e.g. `"zh"` or `"EN-GB"` for a regional variant.
173    /// * `text` - The text to translate. Cannot be empty.
174    /// * `deepl_session` - An optional session string. If `None`, the "Free" method is used; otherwise "Pro".
175    ///
176    /// # Returns
177    ///
178    /// On success, returns a `DeepLXTranslationResult` containing the translated text and alternatives.
179    /// On failure, returns an `Err` containing the underlying error.
180    ///
181    /// # Examples
182    ///
183    /// ```no_run
184    /// use deeplx::{Config, DeepLX};
185    ///
186    /// async fn run() {
187    ///     let translator = DeepLX::new(Config::default());
188    ///     match translator
189    ///         .translate("auto", "zh", "Hello, world!", None)
190    ///         .await {
191    ///         Ok(res) => println!("Translated: {}", res.data),
192    ///         Err(e) => eprintln!("Error: {}", e),
193    ///     }
194    /// }
195    ///
196    /// #[cfg(not(target_arch = "wasm32"))]
197    /// #[tokio::main]
198    /// async fn main() {
199    ///    run().await;
200    /// }
201    ///
202    /// #[cfg(target_arch = "wasm32")]
203    /// #[tokio::main(flavor = "current_thread")]
204    /// async fn main() {
205    ///    run().await;
206    /// }
207    /// ```
208    pub async fn translate(
209        &self,
210        source_lang: &str,
211        target_lang: &str,
212        text: &str,
213        deepl_session: Option<&str>,
214    ) -> Result<DeepLXTranslationResult, Error> {
215        // return if there's nothing to translate
216        if text.is_empty() {
217            return Ok(DeepLXTranslationResult {
218                code: 404,
219                message: Some("No text to translate".to_string()),
220                ..Default::default()
221            });
222        }
223
224        // determine source language
225        let source_lang_detached = match source_lang {
226            "auto" | "" => {
227                let iso_639_3 = whatlang::detect_lang(text)
228                    .ok_or(LangDetectError::from(io::Error::new(
229                        io::ErrorKind::InvalidInput,
230                        "Failed to detect language",
231                    )))?
232                    .code();
233
234                isolang::Language::from_639_3(iso_639_3)
235                    .and_then(|lang| lang.to_639_1())
236                    .map(|iso_639_1| iso_639_1.to_uppercase())
237                    .ok_or(LangDetectError::from(io::Error::new(
238                        io::ErrorKind::InvalidInput,
239                        "Could not map detected language to ISO 639-1",
240                    )))?
241            }
242            _ => source_lang.to_uppercase(),
243        };
244
245        // check target language
246        let target_lang_parts = target_lang.split('-').collect::<Vec<&str>>();
247        let (target_lang_code, _) = if target_lang_parts.len() > 1 {
248            (target_lang_parts[0].to_uppercase(), true)
249        } else {
250            (target_lang.to_uppercase(), false)
251        };
252
253        // prepare the JSON-RPC request
254        let id = get_random_number();
255        let i_count = get_i_count(text);
256        let timestamp = get_timestamp(i_count);
257
258        let post_data = PostData {
259            json_rpc: "2.0",
260            method: "LMT_handle_texts",
261            id,
262            params: Params {
263                splitting: "newlines",
264                lang: Lang {
265                    source_lang_user_selected: source_lang,
266                    target_lang: target_lang_code.as_str(),
267                    ..Default::default()
268                },
269                texts: vec![TextItem {
270                    text,
271                    request_alternatives: 3,
272                }],
273                timestamp,
274            },
275        };
276
277        // send request and parse response
278        let (status, resp) = self.make_request(&post_data, deepl_session).await?;
279        if !status.is_success() {
280            return Ok(DeepLXTranslationResult {
281                code: status.as_u16() as i32,
282                ..Default::default()
283            });
284        }
285
286        let resp: TranslationResponse = resp.json().await?;
287
288        let texts = resp.result.texts;
289        if texts.is_empty() {
290            return Ok(DeepLXTranslationResult {
291                code: 503,
292                message: Some("Translation failed".to_string()),
293                ..Default::default()
294            });
295        }
296
297        let main_translation = texts[0].text.clone();
298
299        // collect alternatives
300        // let num_beams = translations[0].beams.len();
301        let alternatives: Vec<String> = texts
302            .iter()
303            .map(|t| t.text.clone())
304            .filter(|alt| !alt.is_empty())
305            .collect();
306
307        Ok(DeepLXTranslationResult {
308            code: 200,
309            id,
310            data: main_translation,
311            alternatives,
312            source_lang: if resp.result.lang.is_empty() {
313                source_lang_detached
314            } else {
315                resp.result.lang
316            },
317            target_lang: target_lang.to_string(),
318            method: if deepl_session.is_none() {
319                "Free"
320            } else {
321                "Pro"
322            }
323            .to_string(),
324            ..Default::default()
325        })
326    }
327}
328
329const USER_AGENTS: &[&str] = &[
330    "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
331    "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
332    "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
333    "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0",
334    "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:121.0) Gecko/20100101 Firefox/121.0",
335];
336
337const ACCEPT_LANGUAGES: &[&str] = &[
338    "en-US,en;q=0.9",
339    "en-GB,en;q=0.9",
340    "en-US,en;q=0.8,es;q=0.6",
341    "en-US,en;q=0.9,fr;q=0.8",
342    "en-US,en;q=0.9,de;q=0.8",
343];
344
345fn headers() -> HeaderMap {
346    let mut rng = rand::rng();
347
348    let mut headers = HeaderMap::new();
349    headers.insert(
350        CONTENT_TYPE,
351        HeaderValue::from_static("application/json; charset=utf-8"),
352    );
353    headers.insert(
354        USER_AGENT,
355        HeaderValue::from_str(USER_AGENTS.choose(&mut rng).unwrap()).unwrap(),
356    );
357    headers.insert(
358        ACCEPT_LANGUAGE,
359        HeaderValue::from_str(ACCEPT_LANGUAGES.choose(&mut rng).unwrap()).unwrap(),
360    );
361    headers.insert(
362        ACCEPT,
363        HeaderValue::from_static("application/json, text/plain, */*"),
364    );
365    headers.insert(
366        ACCEPT_ENCODING,
367        HeaderValue::from_static("gzip, deflate, br"),
368    );
369    headers.insert(DNT, HeaderValue::from_static("1"));
370    headers.insert(CONNECTION, HeaderValue::from_static("keep-alive"));
371    headers.insert(UPGRADE_INSECURE_REQUESTS, HeaderValue::from_static("1"));
372
373    headers
374}