Skip to main content

capmonstercloud_client/
client.rs

1//! `CapMonsterClient`: createTask / getTaskResult / getBalance and the high-level `solve_captcha`.
2
3use std::time::Duration;
4
5use serde::Deserialize;
6use serde_json::{json, Value};
7use tokio::time::{sleep, Instant};
8
9use crate::error::{Error, Result};
10use crate::proxy::{ClientProxy, ProxyType};
11use crate::requests::CaptchaRequest;
12use crate::timeouts::GetResultTimeouts;
13
14/// Default API endpoint.
15pub const DEFAULT_SERVICE_URL: &str = "https://api.capmonster.cloud";
16/// Default `softId` sent with every task.
17pub const DEFAULT_SOFT_ID: u32 = 152;
18
19/// Client settings. Build with [`ClientOptions::new`] and chain `with_*` methods.
20#[derive(Clone)]
21pub struct ClientOptions {
22    pub api_key: String,
23    pub service_url: String,
24    pub soft_id: u32,
25    /// Timeout of ONE HTTP request (not of the whole solving).
26    pub client_timeout: Duration,
27    /// Proxy for the SDK's own requests to the API (http/https only).
28    pub client_proxy: Option<ClientProxy>,
29    /// If set, replaces the per-captcha-type polling schedule (mostly useful for tests).
30    pub timeouts_override: Option<GetResultTimeouts>,
31}
32
33impl ClientOptions {
34    pub fn new(api_key: impl Into<String>) -> Self {
35        Self {
36            api_key: api_key.into(),
37            service_url: DEFAULT_SERVICE_URL.to_string(),
38            soft_id: DEFAULT_SOFT_ID,
39            client_timeout: Duration::from_secs(20),
40            client_proxy: None,
41            timeouts_override: None,
42        }
43    }
44
45    pub fn with_service_url(mut self, url: impl Into<String>) -> Self {
46        self.service_url = url.into();
47        self
48    }
49
50    pub fn with_soft_id(mut self, soft_id: u32) -> Self {
51        self.soft_id = soft_id;
52        self
53    }
54
55    pub fn with_client_timeout(mut self, timeout: Duration) -> Self {
56        self.client_timeout = timeout;
57        self
58    }
59
60    pub fn with_client_proxy(mut self, proxy: ClientProxy) -> Self {
61        self.client_proxy = Some(proxy);
62        self
63    }
64
65    pub fn with_timeouts_override(mut self, timeouts: GetResultTimeouts) -> Self {
66        self.timeouts_override = Some(timeouts);
67        self
68    }
69}
70
71// Debug is written by hand so that the API key never ends up in logs.
72impl std::fmt::Debug for ClientOptions {
73    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
74        f.debug_struct("ClientOptions")
75            .field("api_key", &"***")
76            .field("service_url", &self.service_url)
77            .field("soft_id", &self.soft_id)
78            .field("client_timeout", &self.client_timeout)
79            .field("client_proxy", &self.client_proxy)
80            .field("timeouts_override", &self.timeouts_override)
81            .finish()
82    }
83}
84
85/// Raw envelope shared by all three API methods; every field is optional because
86/// each method fills only its own subset.
87#[derive(Debug, Deserialize)]
88#[serde(rename_all = "camelCase")]
89struct ApiResponse {
90    error_id: i64,
91    error_code: Option<String>,
92    error_description: Option<String>,
93    task_id: Option<u64>,
94    status: Option<String>,
95    solution: Option<Value>,
96    balance: Option<f64>,
97}
98
99impl ApiResponse {
100    fn code(&self) -> String {
101        self.error_code.clone().unwrap_or_default()
102    }
103    fn description(&self) -> String {
104        self.error_description.clone().unwrap_or_default()
105    }
106}
107
108/// State of a task returned by [`CapMonsterClient::get_task_result`].
109#[derive(Debug, Clone, PartialEq)]
110pub enum TaskStatus {
111    Processing,
112    /// The solution object; its shape depends on the captcha type.
113    Ready(Value),
114}
115
116/// Asynchronous CapMonster Cloud client. Cheap to clone (the HTTP pool is shared).
117#[derive(Debug, Clone)]
118pub struct CapMonsterClient {
119    options: ClientOptions,
120    http: reqwest::Client,
121}
122
123impl CapMonsterClient {
124    pub fn new(options: ClientOptions) -> Result<Self> {
125        let user_agent = format!(
126            "Zennolab.CapMonsterCloud.Client.Rust/{}",
127            env!("CARGO_PKG_VERSION")
128        );
129        let mut builder = reqwest::Client::builder()
130            .user_agent(user_agent)
131            .timeout(options.client_timeout);
132
133        if let Some(p) = &options.client_proxy {
134            if !matches!(p.proxy_type, ProxyType::Http | ProxyType::Https) {
135                return Err(Error::UnsupportedProxyType(
136                    p.proxy_type.as_str().to_string(),
137                ));
138            }
139            let url = format!("{}://{}:{}", p.proxy_type.as_str(), p.address, p.port);
140            let mut proxy = reqwest::Proxy::all(url)?;
141            if let (Some(login), Some(password)) = (&p.login, &p.password) {
142                proxy = proxy.basic_auth(login, password);
143            }
144            builder = builder.proxy(proxy);
145        }
146
147        Ok(Self {
148            options,
149            http: builder.build()?,
150        })
151    }
152
153    pub fn options(&self) -> &ClientOptions {
154        &self.options
155    }
156
157    /// Account balance in USD.
158    pub async fn get_balance(&self) -> Result<f64> {
159        let resp = self
160            .post("getBalance", json!({ "clientKey": self.options.api_key }))
161            .await?;
162        if resp.error_id != 0 {
163            return Err(Error::GetBalance {
164                code: resp.code(),
165                description: resp.description(),
166            });
167        }
168        resp.balance
169            .ok_or_else(|| Error::MalformedResponse("getBalance: no `balance` field".into()))
170    }
171
172    /// Creates a task and returns its id (low-level; most users want [`Self::solve_captcha`]).
173    pub async fn create_task<R: CaptchaRequest>(&self, request: &R) -> Result<u64> {
174        let body = json!({
175            "clientKey": self.options.api_key,
176            "task": request.to_task()?,
177            "softId": self.options.soft_id,
178        });
179        let resp = self.post("createTask", body).await?;
180        if resp.error_id != 0 {
181            return Err(Error::CreateTask {
182                code: resp.code(),
183                description: resp.description(),
184            });
185        }
186        resp.task_id
187            .ok_or_else(|| Error::MalformedResponse("createTask: no `taskId` field".into()))
188    }
189
190    /// One `getTaskResult` call (low-level).
191    pub async fn get_task_result(&self, task_id: u64) -> Result<TaskStatus> {
192        let body = json!({ "clientKey": self.options.api_key, "taskId": task_id });
193        let resp = match self.post("getTaskResult", body).await {
194            Ok(r) => r,
195            // The service sometimes answers 500 while the task is still being processed.
196            Err(Error::Status { status: 500, .. }) => return Ok(TaskStatus::Processing),
197            Err(e) => return Err(e),
198        };
199        if resp.error_id != 0 {
200            return Err(Error::GetResult {
201                code: resp.code(),
202                description: resp.description(),
203            });
204        }
205        match resp.status.as_deref() {
206            Some("ready") => Ok(TaskStatus::Ready(resp.solution.unwrap_or(Value::Null))),
207            _ => Ok(TaskStatus::Processing),
208        }
209    }
210
211    /// Creates a task, polls until it is solved and returns the `solution` object.
212    ///
213    /// Returns `Error::Timeout` if the captcha type's maximum waiting time is exceeded.
214    pub async fn solve_captcha<R: CaptchaRequest>(&self, request: &R) -> Result<Value> {
215        let timeouts = self
216            .options
217            .timeouts_override
218            .unwrap_or_else(|| request.timeouts());
219        let task_id = self.create_task(request).await?;
220
221        let now = Instant::now();
222        // checked_add: a huge user-supplied timeout must not panic
223        let deadline = now
224            .checked_add(timeouts.timeout)
225            .unwrap_or_else(|| now + Duration::from_secs(365 * 24 * 3600));
226        sleep(timeouts.first_request_delay).await;
227        while Instant::now() < deadline {
228            if let TaskStatus::Ready(solution) = self.get_task_result(task_id).await? {
229                return Ok(solution);
230            }
231            sleep(timeouts.requests_interval).await;
232        }
233        Err(Error::Timeout(timeouts.timeout))
234    }
235
236    async fn post(&self, method: &'static str, body: Value) -> Result<ApiResponse> {
237        let url = format!(
238            "{}/{}",
239            self.options.service_url.trim_end_matches('/'),
240            method
241        );
242        let resp = self.http.post(url).json(&body).send().await?;
243        let status = resp.status();
244        if !status.is_success() {
245            return Err(Error::Status {
246                method,
247                status: status.as_u16(),
248            });
249        }
250        // The API sometimes returns JSON with a wrong content-type, so parse the text ourselves.
251        let text = resp.text().await?;
252        Ok(serde_json::from_str(&text)?)
253    }
254}