Skip to main content

capmonstercloud_client/requests/
image_to_text.rs

1use base64::{engine::general_purpose::STANDARD, Engine as _};
2use serde::{Serialize, Serializer};
3
4use super::CaptchaRequest;
5use crate::error::{Error, Result};
6use crate::timeouts::GetResultTimeouts;
7
8/// Recognition modules accepted by `ImageToTextTask` (`capMonsterModule`), as listed in
9/// <https://docs.capmonster.cloud/docs/captchas/ImageToText/module-name.txt>.
10/// A module missing here (the list grows) can be passed with [`TextModule::Custom`].
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub enum TextModule {
13    Amazon,
14    Apple,
15    Botdetect,
16    Facebook,
17    Gmx,
18    Google,
19    Hotmail,
20    Mailru,
21    Okeng,
22    Okrus,
23    Partiallyblur,
24    Ramblerrus,
25    Ramblerrusnew,
26    Solvemedia,
27    Steam,
28    Vk,
29    Whatsapp,
30    WikimediaPaddleEnsemble,
31    Yandex,
32    /// Two-word Yandex captcha.
33    Yandexnew,
34    Yandexwave,
35    /// Latin Yandex captcha.
36    Yandexwavelatin,
37    CaptchaMath,
38    Sat,
39    BlsText,
40    /// All other text captchas.
41    Universal,
42    /// Any other module name (e.g. `special`).
43    Custom(String),
44}
45
46impl TextModule {
47    /// The module name as sent to the API.
48    pub fn as_str(&self) -> &str {
49        match self {
50            TextModule::Amazon => "amazon",
51            TextModule::Apple => "apple",
52            TextModule::Botdetect => "botdetect",
53            TextModule::Facebook => "facebook",
54            TextModule::Gmx => "gmx",
55            TextModule::Google => "google",
56            TextModule::Hotmail => "hotmail",
57            TextModule::Mailru => "mailru",
58            TextModule::Okeng => "okeng",
59            TextModule::Okrus => "okrus",
60            TextModule::Partiallyblur => "partiallyblur",
61            TextModule::Ramblerrus => "ramblerrus",
62            TextModule::Ramblerrusnew => "ramblerrusnew",
63            TextModule::Solvemedia => "solvemedia",
64            TextModule::Steam => "steam",
65            TextModule::Vk => "vk",
66            TextModule::Whatsapp => "whatsapp",
67            TextModule::WikimediaPaddleEnsemble => "wikimedia_paddle_ensemble",
68            TextModule::Yandex => "yandex",
69            TextModule::Yandexnew => "yandexnew",
70            TextModule::Yandexwave => "yandexwave",
71            TextModule::Yandexwavelatin => "yandexwavelatin",
72            TextModule::CaptchaMath => "captcha_math",
73            TextModule::Sat => "sat",
74            TextModule::BlsText => "bls_text",
75            TextModule::Universal => "universal",
76            TextModule::Custom(name) => name,
77        }
78    }
79}
80
81impl Serialize for TextModule {
82    fn serialize<S: Serializer>(&self, s: S) -> std::result::Result<S::Ok, S::Error> {
83        s.serialize_str(self.as_str())
84    }
85}
86
87fn as_base64<S: Serializer>(bytes: &[u8], s: S) -> std::result::Result<S::Ok, S::Error> {
88    s.serialize_str(&STANDARD.encode(bytes))
89}
90
91/// Text-on-image captcha (`ImageToTextTask`).
92#[derive(Debug, Clone, Serialize)]
93pub struct ImageToTextRequest {
94    /// Raw image bytes (PNG/JPEG/...); sent base64-encoded as `body`.
95    #[serde(rename = "body", serialize_with = "as_base64")]
96    pub image_bytes: Vec<u8>,
97    #[serde(rename = "capMonsterModule", skip_serializing_if = "Option::is_none")]
98    pub module_name: Option<TextModule>,
99    /// Minimum recognition confidence, 0..=100.
100    #[serde(
101        rename = "recognizingThreshold",
102        skip_serializing_if = "Option::is_none"
103    )]
104    pub threshold: Option<u8>,
105    /// Case-sensitive text.
106    #[serde(rename = "case", skip_serializing_if = "Option::is_none")]
107    pub case: Option<bool>,
108    /// Only digits (sent as 0/1).
109    #[serde(
110        serialize_with = "bool_as_int",
111        skip_serializing_if = "Option::is_none"
112    )]
113    pub numeric: Option<bool>,
114    /// The image contains an arithmetic expression to calculate.
115    #[serde(skip_serializing_if = "Option::is_none")]
116    pub math: Option<bool>,
117}
118
119fn bool_as_int<S: Serializer>(v: &Option<bool>, s: S) -> std::result::Result<S::Ok, S::Error> {
120    match v {
121        Some(b) => s.serialize_u8(*b as u8),
122        None => s.serialize_none(),
123    }
124}
125
126impl ImageToTextRequest {
127    pub fn new(image_bytes: impl Into<Vec<u8>>) -> Self {
128        Self {
129            image_bytes: image_bytes.into(),
130            module_name: None,
131            threshold: None,
132            case: None,
133            numeric: None,
134            math: None,
135        }
136    }
137
138    pub fn with_module_name(mut self, value: TextModule) -> Self {
139        self.module_name = Some(value);
140        self
141    }
142
143    pub fn with_threshold(mut self, value: u8) -> Self {
144        self.threshold = Some(value);
145        self
146    }
147
148    pub fn with_case(mut self, value: bool) -> Self {
149        self.case = Some(value);
150        self
151    }
152
153    pub fn with_numeric(mut self, value: bool) -> Self {
154        self.numeric = Some(value);
155        self
156    }
157
158    pub fn with_math(mut self, value: bool) -> Self {
159        self.math = Some(value);
160        self
161    }
162}
163
164impl CaptchaRequest for ImageToTextRequest {
165    const TASK_TYPE: &'static str = "ImageToTextTask";
166
167    fn timeouts(&self) -> GetResultTimeouts {
168        GetResultTimeouts::secs(0.35, 0.0, 0.2, 10.0)
169    }
170
171    fn validate(&self) -> Result<()> {
172        if self.image_bytes.is_empty() {
173            return Err(Error::Validation("body must not be empty".into()));
174        }
175        if matches!(self.threshold, Some(t) if t > 100) {
176            return Err(Error::Validation(
177                "threshold must be between 0 and 100".into(),
178            ));
179        }
180        Ok(())
181    }
182}
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187    use serde_json::json;
188
189    #[test]
190    fn body_is_base64_and_numeric_is_int() {
191        let task = ImageToTextRequest::new(b"abc".to_vec())
192            .with_module_name(TextModule::Yandexnew)
193            .with_threshold(90)
194            .with_numeric(true)
195            .to_task()
196            .unwrap();
197        assert_eq!(
198            task,
199            json!({"type": "ImageToTextTask", "body": "YWJj", "capMonsterModule": "yandexnew",
200                   "recognizingThreshold": 90, "numeric": 1})
201        );
202    }
203
204    #[test]
205    fn threshold_out_of_range_is_rejected() {
206        let err = ImageToTextRequest::new(vec![1])
207            .with_threshold(101)
208            .to_task()
209            .unwrap_err();
210        assert!(matches!(err, Error::Validation(_)));
211    }
212
213    #[test]
214    fn empty_image_bytes_are_rejected() {
215        let err = ImageToTextRequest::new(Vec::new()).to_task().unwrap_err();
216        assert!(matches!(err, Error::Validation(ref m) if m.contains("body")));
217    }
218}