Skip to main content

eoka_agent/
captcha.rs

1// Anti-captcha integration for automatic CAPTCHA solving
2// Supports: hCaptcha, reCAPTCHA v2, reCAPTCHA v3
3
4use serde::{Deserialize, Serialize};
5use std::time::Duration;
6
7#[derive(Debug, Clone)]
8pub struct CaptchaConfig {
9    pub api_key: String,
10    pub client_id: u32,
11}
12
13#[derive(Debug, Serialize)]
14pub struct CreateTaskRequest {
15    #[serde(rename = "clientKey")]
16    pub client_key: String,
17    pub task: CaptchaTask,
18}
19
20#[derive(Debug, Serialize)]
21#[serde(tag = "type")]
22pub enum CaptchaTask {
23    #[serde(rename = "HCaptchaTaskProxyless")]
24    HCaptchaProxyless {
25        #[serde(rename = "websiteURL")]
26        website_url: String,
27        #[serde(rename = "websiteKey")]
28        website_key: String,
29    },
30    #[serde(rename = "NoCaptchaTaskProxyless")]
31    ReCaptchaV2Proxyless {
32        #[serde(rename = "websiteURL")]
33        website_url: String,
34        #[serde(rename = "websiteKey")]
35        website_key: String,
36    },
37    #[serde(rename = "RecaptchaV3TaskProxyless")]
38    ReCaptchaV3Proxyless {
39        #[serde(rename = "websiteURL")]
40        website_url: String,
41        #[serde(rename = "websiteKey")]
42        website_key: String,
43        #[serde(rename = "minScore")]
44        min_score: f32,
45        #[serde(rename = "pageAction")]
46        page_action: String,
47    },
48}
49
50#[derive(Debug, Deserialize)]
51pub struct CreateTaskResponse {
52    #[serde(rename = "errorId")]
53    pub error_id: u32,
54    #[serde(rename = "errorCode")]
55    pub error_code: Option<String>,
56    #[serde(rename = "taskId")]
57    pub task_id: Option<u64>,
58}
59
60#[derive(Debug, Serialize)]
61pub struct GetResultRequest {
62    #[serde(rename = "clientKey")]
63    pub client_key: String,
64    #[serde(rename = "taskId")]
65    pub task_id: u64,
66}
67
68#[derive(Debug, Deserialize)]
69pub struct GetResultResponse {
70    #[serde(rename = "errorId")]
71    pub error_id: u32,
72    #[serde(rename = "errorCode")]
73    pub error_code: Option<String>,
74    pub status: Option<String>,
75    pub solution: Option<CaptchaSolution>,
76}
77
78#[derive(Debug, Deserialize)]
79pub struct CaptchaSolution {
80    #[serde(rename = "gRecaptchaResponse")]
81    pub g_recaptcha_response: Option<String>,
82    #[serde(rename = "gRecaptchaResponseWithoutSpaces")]
83    pub g_recaptcha_response_without_spaces: Option<String>,
84    pub text: Option<String>,
85    #[serde(rename = "expireTime")]
86    pub expire_time: Option<i64>,
87}
88
89pub struct AntiCaptcha {
90    client: reqwest::Client,
91    api_key: String,
92}
93
94impl AntiCaptcha {
95    pub fn new(api_key: String) -> Self {
96        Self {
97            client: reqwest::Client::new(),
98            api_key,
99        }
100    }
101
102    /// Solve hCaptcha
103    pub async fn solve_hcaptcha(
104        &self,
105        website_url: &str,
106        website_key: &str,
107    ) -> Result<String, Box<dyn std::error::Error>> {
108        self.solve_captcha(CaptchaTask::HCaptchaProxyless {
109            website_url: website_url.to_string(),
110            website_key: website_key.to_string(),
111        })
112        .await
113    }
114
115    /// Solve reCAPTCHA v2
116    pub async fn solve_recaptcha_v2(
117        &self,
118        website_url: &str,
119        website_key: &str,
120    ) -> Result<String, Box<dyn std::error::Error>> {
121        self.solve_captcha(CaptchaTask::ReCaptchaV2Proxyless {
122            website_url: website_url.to_string(),
123            website_key: website_key.to_string(),
124        })
125        .await
126    }
127
128    /// Solve reCAPTCHA v3
129    pub async fn solve_recaptcha_v3(
130        &self,
131        website_url: &str,
132        website_key: &str,
133        page_action: &str,
134        min_score: f32,
135    ) -> Result<String, Box<dyn std::error::Error>> {
136        self.solve_captcha(CaptchaTask::ReCaptchaV3Proxyless {
137            website_url: website_url.to_string(),
138            website_key: website_key.to_string(),
139            min_score,
140            page_action: page_action.to_string(),
141        })
142        .await
143    }
144
145    /// Generic captcha solver
146    async fn solve_captcha(&self, task: CaptchaTask) -> Result<String, Box<dyn std::error::Error>> {
147        // Create task
148        let create_req = CreateTaskRequest {
149            client_key: self.api_key.clone(),
150            task,
151        };
152
153        let response = self
154            .client
155            .post("https://api.anti-captcha.com/createTask")
156            .json(&create_req)
157            .send()
158            .await?;
159
160        let create_resp: CreateTaskResponse = response.json().await?;
161
162        if create_resp.error_id != 0 {
163            return Err(format!(
164                "Failed to create task: {} - {}",
165                create_resp.error_id,
166                create_resp.error_code.unwrap_or_default()
167            )
168            .into());
169        }
170
171        let task_id = create_resp.task_id.ok_or("No task ID returned")?;
172
173        // Poll for result (max 5 minutes)
174        let max_attempts = 300;
175        for attempt in 0..max_attempts {
176            tokio::time::sleep(Duration::from_millis(500)).await;
177
178            let result_req = GetResultRequest {
179                client_key: self.api_key.clone(),
180                task_id,
181            };
182
183            let response = self
184                .client
185                .post("https://api.anti-captcha.com/getTaskResult")
186                .json(&result_req)
187                .send()
188                .await?;
189
190            let result_resp: GetResultResponse = response.json().await?;
191
192            if result_resp.error_id != 0 {
193                return Err(format!(
194                    "Failed to get result: {} - {}",
195                    result_resp.error_id,
196                    result_resp.error_code.unwrap_or_default()
197                )
198                .into());
199            }
200
201            if result_resp.status.as_deref() == Some("ready") {
202                if let Some(solution) = result_resp.solution {
203                    return Ok(solution
204                        .g_recaptcha_response
205                        .or(solution.g_recaptcha_response_without_spaces)
206                        .or(solution.text)
207                        .ok_or("No solution in response")?);
208                }
209                return Err("No solution data returned".into());
210            }
211
212            // Log progress occasionally
213            if attempt % 10 == 0 && attempt > 0 {
214                eprintln!(
215                    "Captcha solving in progress... ({}/{}s)",
216                    attempt / 2,
217                    max_attempts / 2
218                );
219            }
220        }
221
222        Err("Captcha solving timeout (5 minutes)".into())
223    }
224
225    /// Detect captcha on page and return sitekey
226    pub async fn detect_captcha_on_page(page: &eoka::Page) -> Option<CaptchaInfo> {
227        // Check for hCaptcha
228        let hcaptcha_script = r#"
229            (function() {
230                const elem = document.querySelector('[data-sitekey]');
231                if (elem && elem.getAttribute('data-sitekey')) {
232                    return elem.getAttribute('data-sitekey');
233                }
234                return null;
235            })()
236        "#;
237
238        if let Ok(result) = page.evaluate::<serde_json::Value>(hcaptcha_script).await {
239            if let Some(key_str) = result.as_str() {
240                if !key_str.is_empty() {
241                    return Some(CaptchaInfo {
242                        captcha_type: "hcaptcha".to_string(),
243                        sitekey: key_str.to_string(),
244                    });
245                }
246            }
247        }
248
249        // Check for reCAPTCHA
250        let recaptcha_script = r#"
251            (function() {
252                const scripts = document.querySelectorAll('script');
253                for (const script of scripts) {
254                    if (script.src && script.src.includes('recaptcha')) {
255                        const matches = document.documentElement.innerHTML.match(/"sitekey"\s*:\s*"([^"]+)"/);
256                        if (matches) return matches[1];
257                    }
258                }
259                return null;
260            })()
261        "#;
262
263        if let Ok(result) = page.evaluate::<serde_json::Value>(recaptcha_script).await {
264            if let Some(key_str) = result.as_str() {
265                if !key_str.is_empty() {
266                    return Some(CaptchaInfo {
267                        captcha_type: "recaptcha".to_string(),
268                        sitekey: key_str.to_string(),
269                    });
270                }
271            }
272        }
273
274        None
275    }
276}
277
278#[derive(Debug, Clone)]
279pub struct CaptchaInfo {
280    pub captcha_type: String,
281    pub sitekey: String,
282}