use super::*;
#[cfg(feature = "vision")]
use anyhow::Context;
#[cfg(feature = "vision")]
use base64::Engine as _;
pub struct CrnnTextSolver {
#[allow(dead_code)] recognizer: Option<tokio::sync::Mutex<CrnnHandle>>,
config: SolveConfig,
}
#[cfg(feature = "vision")]
struct CrnnHandle(crate::vision::crnn::CrnnRecognizer);
#[cfg(not(feature = "vision"))]
struct CrnnHandle(());
impl Default for CrnnTextSolver {
fn default() -> Self {
Self::new()
}
}
impl CrnnTextSolver {
pub fn new() -> Self {
Self {
recognizer: None,
config: SolveConfig::default(),
}
}
pub fn with_config(mut self, config: SolveConfig) -> Self {
self.config = config;
self
}
}
#[async_trait]
impl CaptchaSolver for CrnnTextSolver {
fn name(&self) -> &'static str {
"CrnnTextSolver"
}
fn method(&self) -> SolveMethod {
SolveMethod::VisionLLM
}
fn supports(&self, kind: &crate::captcha_detect::DetectedCaptcha) -> bool {
#[cfg(feature = "vision")]
{
matches!(
kind,
crate::captcha_detect::DetectedCaptcha::ImageCaptcha
| crate::captcha_detect::DetectedCaptcha::CanvasCaptcha
| crate::captcha_detect::DetectedCaptcha::ShadowDomCaptcha
)
}
#[cfg(not(feature = "vision"))]
{
let _ = kind;
false
}
}
async fn solve(
&self,
page: &Page,
_captcha_info: &CaptchaInfo,
) -> Result<CaptchaSolveResult> {
let _ = page;
let t0 = Instant::now();
#[cfg(not(feature = "vision"))]
{
return Ok(CaptchaSolveResult::failure(self.method(), t0.elapsed().as_millis() as u64));
}
#[cfg(feature = "vision")]
{
let hub = crate::vision::ModelHub::new();
let path = hub.resolve(crate::vision::ModelId::CrnnText).await
.map_err(|e| anyhow!("CRNN model unavailable: {e}"))?;
let charset = crate::vision::crnn::default_charset();
let mut recognizer = crate::vision::crnn::CrnnRecognizer::load(&path, charset)
.map_err(|e| anyhow!("CRNN load failed: {e}"))?;
let screenshot_b64 = super::util::screenshot_b64(page).await?;
let screenshot_bytes = base64::engine::general_purpose::STANDARD
.decode(screenshot_b64.as_bytes())
.map_err(|e| anyhow::anyhow!("decode screenshot: {e}"))?;
let image = image::load_from_memory(&screenshot_bytes)
.context("load screenshot as image")?;
let (text, confidence) = recognizer.recognize(&image)?;
if text.is_empty() || confidence < 0.3 {
return Ok(CaptchaSolveResult::failure(
self.method(),
t0.elapsed().as_millis() as u64,
));
}
let type_js = format!(
r#"
(() => {{
const probes = [
'input[type="text"]',
'.captcha-input',
'#captcha-answer',
'#verification_code',
'input[name*="captcha"]',
'input[name*="verify"]',
];
for (const sel of probes) {{
const el = document.querySelector(sel);
if (el) {{
el.focus();
el.value = '{}';
el.dispatchEvent(new Event('input', {{ bubbles: true }}));
el.dispatchEvent(new Event('change', {{ bubbles: true }}));
return true;
}}
}}
return false;
}})()
"#,
text.replace('\\', "\\\\").replace('"', "\\\"").replace('\'', "\\'")
);
let typed = page.evaluate(type_js).await
.ok()
.and_then(|r| r.into_value::<bool>().ok())
.unwrap_or(false);
if !typed {
return Ok(CaptchaSolveResult::failure(
self.method(),
t0.elapsed().as_millis() as u64,
));
}
let verify_js = r#"
(() => {
const probes = [
'button[type="submit"]',
'input[type="submit"]',
'.verify-button',
'#submit',
'button',
];
for (const sel of probes) {
const el = document.querySelector(sel);
if (el && el.offsetParent !== null) {
el.click();
return true;
}
}
return false;
})()
"#;
let _ = page.evaluate(verify_js).await;
tokio::time::sleep(Duration::from_millis(500)).await;
let token = poll_for_token(page, self.config).await;
let elapsed = t0.elapsed().as_millis() as u64;
Ok(CaptchaSolveResult {
solution: token.clone(),
confidence: if token.is_empty() { confidence * 0.5 } else { confidence },
method: self.method(),
time_ms: elapsed,
success: !token.is_empty(),
screenshot: None,
cookies: Vec::new(),
verified_outcome: None,
})
}
}
}
#[cfg(feature = "vision")]
async fn poll_for_token(page: &Page, config: SolveConfig) -> String {
let deadline = Instant::now() + Duration::from_millis(config.token_max_attempts as u64 * config.token_poll_interval_ms);
loop {
let raw = page
.evaluate(crate::solver::wait_for_token::TOKEN_PROBE_JS)
.await;
if let Ok(Ok(Some(t))) = raw.map(|r| r.into_value::<Option<String>>()) {
if !t.is_empty() {
return t;
}
}
if Instant::now() >= deadline {
return String::new();
}
tokio::time::sleep(Duration::from_millis(config.token_poll_interval_ms)).await;
}
}