use super::*;
use crate::captcha_detect::DetectedCaptcha;
use std::time::Duration;
mod protocol;
mod retry;
pub(crate) use retry::*;
#[cfg(test)]
#[path = "third_party/tests.rs"]
mod tests;
const POLL_INTERVAL_MS: u64 = 5_000;
const MAX_POLLS: u32 = 30;
const ENV_API_KEY: &str = "CAPTCHAFORGE_THIRDPARTY_API_KEY";
const ENV_ENDPOINT: &str = "CAPTCHAFORGE_THIRDPARTY_ENDPOINT";
#[derive(Debug, Clone)]
pub enum ThirdPartyService {
TwoCaptcha,
CapMonster,
CapSolver,
Custom { base_url: String },
}
impl ThirdPartyService {
pub fn base_url(&self) -> &str {
match self {
Self::TwoCaptcha => "https://2captcha.com",
Self::CapMonster => "https://api.capmonster.cloud",
Self::CapSolver => "https://api.capsolver.com",
Self::Custom { base_url } => base_url,
}
}
}
#[derive(Debug)]
pub(crate) struct KeyPool {
keys: Vec<String>,
cursor: std::sync::atomic::AtomicUsize,
}
impl KeyPool {
fn new(keys: Vec<String>) -> Self {
Self {
keys,
cursor: std::sync::atomic::AtomicUsize::new(0),
}
}
pub(crate) fn advance(&self) -> String {
let n = self.keys.len();
if n == 0 {
return String::new();
}
let prev = self
.cursor
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
self.keys[(prev + 1) % n].clone()
}
#[cfg(test)]
pub(crate) fn current(&self) -> String {
let n = self.keys.len();
if n == 0 {
return String::new();
}
let idx = self.cursor.load(std::sync::atomic::Ordering::SeqCst);
self.keys[idx % n].clone()
}
}
pub struct ThirdPartyCaptchaSolver {
pub(crate) client: reqwest::Client,
pub(crate) service: ThirdPartyService,
pub(crate) api_key: Option<String>,
pub(crate) poll_interval_ms: u64,
pub(crate) max_polls: u32,
pub(crate) key_pool: Option<std::sync::Arc<KeyPool>>,
}
impl Default for ThirdPartyCaptchaSolver {
fn default() -> Self {
Self::two_captcha()
}
}
fn validate_endpoint_url(raw: &str) -> bool {
let Ok(parsed) = url::Url::parse(raw) else {
return false;
};
if !matches!(parsed.scheme(), "http" | "https") {
return false;
}
let Some(host) = parsed.host() else {
return false;
};
match host {
url::Host::Ipv4(addr) => {
!(addr.is_loopback()
|| addr.is_private()
|| addr.is_link_local()
|| addr.is_unspecified())
}
url::Host::Ipv6(addr) => {
if let Some(v4) = addr.to_ipv4_mapped() {
if v4.is_loopback() || v4.is_private() || v4.is_link_local() {
return false;
}
}
let seg = addr.segments();
!(addr.is_loopback()
|| addr.is_unspecified()
|| (seg[0] & 0xffc0) == 0xfe80
|| (seg[0] & 0xfe00) == 0xfc00)
}
url::Host::Domain(d) => {
let lower = d.to_ascii_lowercase();
!matches!(
lower.as_str(),
"localhost"
| "ip6-localhost"
| "ip6-loopback"
| "metadata.google.internal"
| "metadata.aws.internal"
)
}
}
}
impl ThirdPartyCaptchaSolver {
pub fn two_captcha() -> Self {
Self::for_service(ThirdPartyService::TwoCaptcha)
}
pub fn cap_monster() -> Self {
Self::for_service(ThirdPartyService::CapMonster)
}
pub fn cap_solver() -> Self {
Self::for_service(ThirdPartyService::CapSolver)
}
pub fn custom_endpoint(base_url: impl Into<String>) -> Self {
Self::for_service(ThirdPartyService::Custom {
base_url: base_url.into(),
})
}
fn for_service(service: ThirdPartyService) -> Self {
let service = match std::env::var(ENV_ENDPOINT) {
Ok(custom) if !custom.is_empty() => {
if validate_endpoint_url(&custom) {
ThirdPartyService::Custom { base_url: custom }
} else {
tracing::warn!(
endpoint = %custom,
"CAPTCHAFORGE_THIRDPARTY_ENDPOINT failed SSRF policy. \
falling back to declared service"
);
service
}
}
_ => service,
};
let api_key = std::env::var(ENV_API_KEY).ok().filter(|s| !s.is_empty());
Self {
client: crate::http_client::timed_client_or_panic(Duration::from_secs(60)),
service,
api_key,
poll_interval_ms: POLL_INTERVAL_MS,
max_polls: MAX_POLLS,
key_pool: None,
}
}
pub fn with_api_key(mut self, key: impl Into<String>) -> Self {
self.api_key = Some(key.into());
self.key_pool = None;
self
}
#[must_use = "with_api_key_pool returns Self; assign or chain it"]
pub fn with_api_key_pool<I, S>(mut self, keys: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
let pool: Vec<String> = keys
.into_iter()
.map(Into::into)
.filter(|k| !k.is_empty())
.collect();
if pool.is_empty() {
self.key_pool = None;
return self;
}
self.api_key = pool.first().cloned();
self.key_pool = Some(std::sync::Arc::new(KeyPool::new(pool)));
self
}
pub fn rotate_api_key(&mut self) -> Option<String> {
let pool = self.key_pool.as_ref()?;
let next = pool.advance();
self.api_key = Some(next.clone());
Some(next)
}
pub fn with_poll_interval_ms(mut self, ms: u64) -> Self {
self.poll_interval_ms = ms;
self
}
pub fn with_max_polls(mut self, polls: u32) -> Self {
self.max_polls = polls;
self
}
pub fn has_api_key(&self) -> bool {
self.api_key.as_ref().is_some_and(|k| !k.is_empty())
}
}
#[async_trait]
impl CaptchaSolver for ThirdPartyCaptchaSolver {
fn name(&self) -> &'static str {
"ThirdPartyCaptchaSolver"
}
fn method(&self) -> SolveMethod {
SolveMethod::ThirdPartyService
}
fn supports(&self, kind: &DetectedCaptcha) -> bool {
if !self.has_api_key() {
return false;
}
match kind {
DetectedCaptcha::Turnstile
| DetectedCaptcha::RecaptchaV2
| DetectedCaptcha::RecaptchaV3
| DetectedCaptcha::HCaptcha => true,
DetectedCaptcha::Custom(name) => matches!(
name.as_str(),
"datadome" | "arkose_funcaptcha" | "geetest_v3" | "geetest_v4"
),
_ => false,
}
}
async fn solve(&self, page: &Page, captcha_info: &CaptchaInfo) -> Result<CaptchaSolveResult> {
let t0 = Instant::now();
if !self.has_api_key() {
return Err(anyhow!("ThirdPartyCaptchaSolver has no API key configured"));
}
let params = Self::build_task_params(captcha_info).ok_or_else(|| {
anyhow!(
"ThirdPartyCaptchaSolver cannot build task params for {:?}",
captcha_info.kind
)
})?;
let task_id = self.submit_task(¶ms).await?;
let token = self.poll_result(&task_id).await?;
let injection = match &captcha_info.kind {
DetectedCaptcha::Turnstile => Some(format!(
"(() => {{ const inp = document.querySelector('[name=\"cf-turnstile-response\"]'); if (inp) inp.value = {}; }})()",
serde_json::to_string(&token).unwrap_or_else(|_| "''".into())
)),
DetectedCaptcha::RecaptchaV2 | DetectedCaptcha::RecaptchaV3 => Some(format!(
"(() => {{ const inp = document.querySelector('[name=\"g-recaptcha-response\"]'); if (inp) inp.value = {0}; const el = document.getElementById('g-recaptcha-response'); if (el) el.innerHTML = {0}; }})()",
serde_json::to_string(&token).unwrap_or_else(|_| "''".into())
)),
DetectedCaptcha::HCaptcha => Some(format!(
"(() => {{ const inp = document.querySelector('[name=\"h-captcha-response\"]'); if (inp) inp.value = {}; }})()",
serde_json::to_string(&token).unwrap_or_else(|_| "''".into())
)),
_ => None,
};
if let Some(js) = injection {
if let Err(e) = page.evaluate(js).await {
tracing::warn!(
"third-party token obtained but injecting it into the page response field failed \
({e}); the token is still returned in `solution` for manual placement"
);
}
}
let cookies = crate::cookies::capture_from_page(page)
.await
.unwrap_or_default();
Ok(CaptchaSolveResult {
solution: token,
confidence: 1.0,
method: SolveMethod::ThirdPartyService,
time_ms: t0.elapsed().as_millis() as u64,
success: true,
screenshot: None,
cookies,
verified_outcome: None,
})
}
}