use super::*;
use crate::backends::OcrBackend;
pub struct OcrCaptchaSolver {
pub(crate) backend: Option<OcrBackend>,
pub(crate) config: SolveConfig,
}
impl Default for OcrCaptchaSolver {
fn default() -> Self {
Self::new()
}
}
impl OcrCaptchaSolver {
pub fn new() -> Self {
let backend = crate::backends::which("tesseract").map(|binary| OcrBackend { binary });
Self {
backend,
config: SolveConfig::default(),
}
}
pub fn with_backend(mut self, backend: OcrBackend) -> Self {
self.backend = Some(backend);
self
}
pub fn with_config(mut self, config: SolveConfig) -> Self {
self.config = config;
self
}
pub async fn ocr(&self, image_bytes: Vec<u8>) -> Result<String> {
let backend = self
.backend
.as_ref()
.ok_or_else(|| anyhow::anyhow!("tesseract not detected on PATH"))?;
let bin = backend.binary.clone();
let mut tmp = tempfile::Builder::new()
.prefix("captchaforge-ocr-")
.suffix(".png")
.tempfile()?;
use std::io::Write;
tmp.write_all(&image_bytes)?;
tmp.flush()?;
let in_path = tmp.path().to_path_buf();
tokio::task::spawn_blocking(move || -> Result<String> {
let out = std::process::Command::new(&bin)
.arg(&in_path)
.arg("-")
.args(["--psm", "7"])
.output()
.map_err(|e| anyhow::anyhow!("spawning tesseract: {e}"))?;
if !out.status.success() {
anyhow::bail!(
"tesseract exited {}: {}",
out.status,
String::from_utf8_lossy(&out.stderr)
);
}
let body = String::from_utf8_lossy(&out.stdout).trim().to_string();
Ok(body)
})
.await
.map_err(|e| anyhow::anyhow!("tesseract task join: {e}"))?
}
}
#[async_trait]
impl CaptchaSolver for OcrCaptchaSolver {
fn name(&self) -> &'static str {
"OcrCaptchaSolver"
}
fn method(&self) -> SolveMethod {
SolveMethod::VisionLLM
}
fn supports(&self, kind: &crate::captcha_detect::DetectedCaptcha) -> bool {
use crate::captcha_detect::DetectedCaptcha;
if self.backend.is_none() {
return false;
}
matches!(
kind,
DetectedCaptcha::CanvasCaptcha
| DetectedCaptcha::ImageCaptcha
| DetectedCaptcha::Custom(_)
)
}
async fn solve(
&self,
page: &Page,
_info: &crate::captcha_detect::CaptchaInfo,
) -> Result<CaptchaSolveResult> {
let t0 = Instant::now();
if self.backend.is_none() {
return Ok(CaptchaSolveResult::failure(
SolveMethod::VisionLLM,
t0.elapsed().as_millis() as u64,
));
}
let image_b64 = match page
.evaluate(
r#"(function(){
const el = document.querySelector(
'canvas.captcha, canvas#captcha, canvas, svg, img.captcha, .captcha-image img'
);
if(!el) return '';
if(el.tagName === 'CANVAS') return el.toDataURL('image/png').split(',')[1];
if(el.tagName === 'IMG'){
const c = document.createElement('canvas');
c.width = el.naturalWidth || el.width;
c.height = el.naturalHeight || el.height;
c.getContext('2d').drawImage(el, 0, 0);
return c.toDataURL('image/png').split(',')[1];
}
if(el.tagName === 'svg' || el.tagName === 'SVG'){
const xml = new XMLSerializer().serializeToString(el);
return btoa(unescape(encodeURIComponent(xml)));
}
return '';
})()"#,
)
.await?
.into_value::<String>()
{
Ok(s) if !s.is_empty() => s,
_ => {
return Ok(CaptchaSolveResult::failure(
SolveMethod::VisionLLM,
t0.elapsed().as_millis() as u64,
));
}
};
use base64::Engine as _;
let image_bytes = base64::engine::general_purpose::STANDARD
.decode(image_b64.as_bytes())
.map_err(|e| anyhow::anyhow!("decoding screenshot b64: {e}"))?;
let raw = match self.ocr(image_bytes).await {
Ok(t) => t,
Err(_) => {
return Ok(CaptchaSolveResult::failure(
SolveMethod::VisionLLM,
t0.elapsed().as_millis() as u64,
));
}
};
let cleaned: String = raw.chars().filter(|c| c.is_alphanumeric()).collect();
if cleaned.is_empty() {
return Ok(CaptchaSolveResult::failure(
SolveMethod::VisionLLM,
t0.elapsed().as_millis() as u64,
));
}
if let Ok(input) = page
.find_element("input[type=text], input[name='captcha'], input[name='answer'], textarea")
.await
{
input.click().await.ok();
input.type_str(&cleaned).await.ok();
}
let cookies = crate::cookies::capture_from_page(page)
.await
.unwrap_or_default();
Ok(CaptchaSolveResult {
solution: cleaned.clone(),
confidence: 0.6,
method: SolveMethod::VisionLLM,
time_ms: t0.elapsed().as_millis() as u64,
success: !cleaned.is_empty(),
screenshot: None,
cookies,
verified_outcome: None,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ocr_solver_inert_when_no_backend() {
let s = OcrCaptchaSolver {
backend: None,
config: SolveConfig::default(),
};
use crate::captcha_detect::DetectedCaptcha;
assert!(!s.supports(&DetectedCaptcha::CanvasCaptcha));
}
#[test]
fn ocr_solver_supports_visual_text_kinds_when_present() {
let s = OcrCaptchaSolver {
backend: Some(OcrBackend {
binary: "/usr/bin/tesseract".into(),
}),
config: SolveConfig::default(),
};
use crate::captcha_detect::DetectedCaptcha;
assert!(s.supports(&DetectedCaptcha::CanvasCaptcha));
assert!(s.supports(&DetectedCaptcha::ImageCaptcha));
assert!(s.supports(&DetectedCaptcha::Custom("svg_captcha".into())));
assert!(!s.supports(&DetectedCaptcha::AudioCaptcha));
assert!(!s.supports(&DetectedCaptcha::Turnstile));
}
#[test]
fn ocr_solver_method_is_vision_llm() {
let s = OcrCaptchaSolver::new();
assert_eq!(s.method(), SolveMethod::VisionLLM);
assert_eq!(s.name(), "OcrCaptchaSolver");
}
}