use async_trait::async_trait;
use thiserror::Error;
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum CaptchaKind {
RecaptchaV2,
Turnstile,
HCaptcha,
Unknown(String),
}
#[derive(Debug, Clone)]
pub struct CaptchaChallenge {
pub page_url: String,
pub kind: CaptchaKind,
}
#[derive(Clone, PartialEq, Eq)]
pub struct CaptchaSolution {
pub token: String,
pub solved: bool,
}
impl std::fmt::Debug for CaptchaSolution {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("CaptchaSolution")
.field("token", &"[redacted]")
.field("solved", &self.solved)
.finish()
}
}
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum CaptchaError {
#[error("captcha solve failed: {0}")]
SolveFailed(String),
#[error("captcha solve requires browser page")]
NeedsBrowser,
#[error("captchaforge returned no solution")]
NoSolution,
}
#[async_trait]
pub trait CaptchaSolver: Send + Sync {
async fn solve(&self, challenge: &CaptchaChallenge) -> Result<CaptchaSolution, CaptchaError>;
}
#[derive(Debug, Clone)]
pub struct StubCaptchaSolver {
token: String,
}
impl StubCaptchaSolver {
#[must_use]
pub fn new(token: impl Into<String>) -> Self {
Self {
token: token.into(),
}
}
}
#[async_trait]
impl CaptchaSolver for StubCaptchaSolver {
async fn solve(&self, challenge: &CaptchaChallenge) -> Result<CaptchaSolution, CaptchaError> {
let _ = challenge;
Ok(CaptchaSolution {
token: self.token.clone(),
solved: true,
})
}
}
#[cfg(all(feature = "browser", feature = "captchaforge"))]
pub async fn solve_via_captchaforge(
page: &runtime_headless::Page,
challenge: &CaptchaChallenge,
) -> Result<CaptchaSolution, CaptchaError> {
use captchaforge::solver::CaptchaSolveResult;
let _ = challenge;
let result = captchaforge::auto_solve(page)
.await
.map_err(|e| CaptchaError::SolveFailed(e.to_string()))?;
let Some(CaptchaSolveResult {
solution, success, ..
}) = result
else {
return Err(CaptchaError::NoSolution);
};
if !success || solution.is_empty() {
return Err(CaptchaError::SolveFailed(
"captchaforge reported unsuccessful solve".into(),
));
}
Ok(CaptchaSolution {
token: solution,
solved: true,
})
}
#[cfg(all(feature = "browser", feature = "captchaforge"))]
#[derive(Debug, Default)]
pub struct CaptchaForgeSolver;
#[cfg(all(feature = "browser", feature = "captchaforge"))]
#[async_trait]
impl CaptchaSolver for CaptchaForgeSolver {
async fn solve(&self, _challenge: &CaptchaChallenge) -> Result<CaptchaSolution, CaptchaError> {
Err(CaptchaError::NeedsBrowser)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn stub_solver_returns_configured_token() {
let solver = StubCaptchaSolver::new("test-token-xyz");
let solution = solver
.solve(&CaptchaChallenge {
page_url: "https://app.example/login".into(),
kind: CaptchaKind::RecaptchaV2,
})
.await
.expect("solve");
assert!(solution.solved);
assert_eq!(solution.token, "test-token-xyz");
}
#[tokio::test]
async fn stub_solver_ignores_challenge_kind() {
let solver = StubCaptchaSolver::new("tok");
let solution = solver
.solve(&CaptchaChallenge {
page_url: "https://x.test/".into(),
kind: CaptchaKind::Unknown("custom".into()),
})
.await
.expect("solve");
assert_eq!(solution.token, "tok");
}
}