use super::grid_click;
use super::*;
mod providers;
pub use providers::VlmProvider;
use providers::*;
mod protocol;
pub(crate) use protocol::*;
pub struct VlmCaptchaSolver {
pub(crate) client: reqwest::Client,
pub(crate) provider: VlmProvider,
pub(crate) endpoint: String,
pub(crate) model: String,
pub(crate) api_key: Option<String>,
pub(crate) config: SolveConfig,
}
impl Default for VlmCaptchaSolver {
fn default() -> Self {
Self::new()
}
}
impl VlmCaptchaSolver {
pub fn new() -> Self {
Self::new_with_env(|name| std::env::var(name).ok())
}
pub(crate) fn new_with_env<F>(env: F) -> Self
where
F: Fn(&str) -> Option<String>,
{
let config = SolveConfig::default();
let api_key_anthropic = env(ENV_ANTHROPIC_KEY).filter(|s| !s.is_empty());
let api_key_openai = env(ENV_OPENAI_KEY).filter(|s| !s.is_empty());
let llama_endpoint = env(ENV_LLAMACPP_ENDPOINT).filter(|s| !s.is_empty());
let (provider, api_key) = if let Some(k) = api_key_anthropic {
(VlmProvider::Anthropic, Some(k))
} else if let Some(k) = api_key_openai {
(VlmProvider::OpenAI, Some(k))
} else if llama_endpoint.is_some() {
(VlmProvider::LlamaCpp, None)
} else {
(VlmProvider::Ollama, None)
};
let endpoint = env(ENV_VLM_ENDPOINT)
.filter(|s| !s.is_empty())
.or_else(|| env(ENV_LLAMACPP_ENDPOINT).filter(|s| !s.is_empty()))
.unwrap_or_else(|| provider.default_endpoint().to_string());
let model = env(ENV_VLM_MODEL)
.filter(|s| !s.is_empty())
.unwrap_or_else(|| provider.default_model().to_string());
Self {
client: crate::http_client::timed_client_or_panic(Duration::from_millis(
config.client_http_timeout_ms,
)),
provider,
endpoint,
model,
api_key,
config,
}
}
pub fn with_provider(mut self, provider: VlmProvider) -> Self {
self.provider = provider;
if self.endpoint.is_empty() {
self.endpoint = provider.default_endpoint().to_string();
}
self
}
pub fn with_api_key(mut self, key: impl Into<String>) -> Self {
self.api_key = Some(key.into());
self
}
pub fn with_endpoint(mut self, url: impl Into<String>) -> Self {
self.endpoint = url.into();
self
}
pub fn with_model(mut self, model: impl Into<String>) -> Self {
self.model = model.into();
self
}
pub fn with_config(mut self, config: SolveConfig) -> Self {
self.config = config;
self
}
pub fn grid_prompt(task: &str) -> String {
format!(
r#"You are solving an image-grid CAPTCHA.
Task: {}
Respond with a JSON object exactly like this:
{{"selected": [0,1,2], "confidence": 0.95}}
where `selected` is a list of zero-based tile indices that match the task.
If none match, use an empty list.
Only respond with the JSON object, no extra text."#,
task
)
}
pub fn text_captcha_prompt() -> String {
r#"You are solving a text CAPTCHA.
Read the text in the image and respond with a JSON object exactly like this:
{"text": "answer", "confidence": 0.95}
Only respond with the JSON object, no extra text."#
.to_string()
}
pub fn click_target_prompt(target: &str) -> String {
format!(
r#"You are solving a click-target CAPTCHA.
Click the object described as: "{}"
Respond with a JSON object exactly like this:
{{"x": 123, "y": 456, "confidence": 0.95}}
where x and y are pixel coordinates in the screenshot.
Only respond with the JSON object, no extra text."#,
target
)
}
}
#[async_trait]
impl CaptchaSolver for VlmCaptchaSolver {
fn name(&self) -> &'static str {
"VlmCaptchaSolver"
}
fn method(&self) -> SolveMethod {
SolveMethod::VisionLLM
}
fn supports(&self, kind: &crate::captcha_detect::DetectedCaptcha) -> bool {
use crate::captcha_detect::DetectedCaptcha;
matches!(
kind,
DetectedCaptcha::RecaptchaV2
| DetectedCaptcha::RecaptchaV3
| DetectedCaptcha::HCaptcha
| DetectedCaptcha::ImageCaptcha
| DetectedCaptcha::Turnstile
| DetectedCaptcha::CanvasCaptcha
| DetectedCaptcha::ShadowDomCaptcha
| DetectedCaptcha::MultiStepCaptcha
| DetectedCaptcha::SliderCaptcha
| DetectedCaptcha::Custom(_)
)
}
async fn solve(
&self,
page: &Page,
captcha_info: &crate::captcha_detect::CaptchaInfo,
) -> Result<CaptchaSolveResult> {
let t0 = Instant::now();
let image_b64 = screenshot_b64(page).await?;
match captcha_info.kind {
crate::captcha_detect::DetectedCaptcha::RecaptchaV2
| crate::captcha_detect::DetectedCaptcha::HCaptcha
| crate::captcha_detect::DetectedCaptcha::ImageCaptcha => {
let task_text = page
.evaluate(
r#"(() => {
const probes = [
'.rc-imageselect-desc-no-canonical', '.rc-imageselect-desc',
'.task-text', '.captcha-prompt',
'#widget > div', '.captcha-task'
];
for (const sel of probes) {
const el = document.querySelector(sel);
if (el && el.textContent && el.textContent.trim().length > 0) {
return el.textContent.trim();
}
}
return 'Select all matching images';
})()"#,
)
.await
.ok()
.and_then(|r| r.into_value::<String>().ok())
.unwrap_or_else(|| "Select all matching images".to_string());
let prompt = Self::grid_prompt(&task_text);
let raw = vlm_query(
&self.client,
self.provider,
&self.endpoint,
&self.model,
self.api_key.as_deref(),
&image_b64,
&prompt,
self.config.vlm_http_timeout_ms,
)
.await?;
let json_str = extract_json(&raw).unwrap_or(&raw);
let val: serde_json::Value =
serde_json::from_str(json_str).unwrap_or(serde_json::Value::Null);
let confidence = val["confidence"].as_f64().unwrap_or(0.5) as f32;
let selected: Vec<usize> = val
.get("selected")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_u64().map(|n| n as usize))
.collect()
})
.unwrap_or_default();
let clicked = grid_click::click_grid_tiles_trusted(page, &selected).await;
if clicked < selected.len() {
tracing::warn!(
"VLM selected {} grid tile(s) but only {} trusted click(s) landed; \
not claiming success, the grid selection is incomplete",
selected.len(),
clicked
);
}
let success = confidence > 0.5 && !selected.is_empty() && clicked == selected.len();
let cookies = if success {
crate::cookies::capture_from_page(page)
.await
.unwrap_or_default()
} else {
Vec::new()
};
Ok(CaptchaSolveResult {
solution: serde_json::to_string(&selected).unwrap_or_default(),
confidence,
method: SolveMethod::VisionLLM,
time_ms: t0.elapsed().as_millis() as u64,
success,
screenshot: None,
cookies,
verified_outcome: None,
})
}
crate::captcha_detect::DetectedCaptcha::RecaptchaV3
| crate::captcha_detect::DetectedCaptcha::CanvasCaptcha
| crate::captcha_detect::DetectedCaptcha::ShadowDomCaptcha => {
let prompt = Self::text_captcha_prompt();
let raw = vlm_query(
&self.client,
self.provider,
&self.endpoint,
&self.model,
self.api_key.as_deref(),
&image_b64,
&prompt,
self.config.vlm_http_timeout_ms,
)
.await?;
let json_str = extract_json(&raw).unwrap_or(&raw);
let val: serde_json::Value =
serde_json::from_str(json_str).unwrap_or(serde_json::Value::Null);
let text = val["text"].as_str().unwrap_or("").trim().to_string();
let confidence = val["confidence"].as_f64().unwrap_or(0.5) as f32;
let typed_via_pierce = page
.evaluate(
format!(
r#"((answer) => {{
function* walkAllRoots(root) {{
const queue = [root];
const seen = new WeakSet();
while (queue.length) {{
const r = queue.shift();
if (seen.has(r)) continue;
seen.add(r);
yield r;
const subtree = r.querySelectorAll ? r.querySelectorAll('*') : [];
for (const el of subtree) {{
if (el.shadowRoot) queue.push(el.shadowRoot);
if (el.tagName === 'IFRAME') {{
let inner = null;
try {{ inner = el.contentDocument; }} catch (_) {{}}
if (inner) queue.push(inner);
}}
}}
}}
}}
for (const root of walkAllRoots(document)) {{
let inp = null;
try {{ inp = root.querySelector('input[type=text], input:not([type]), textarea, .rc-response-input, input[name="captcha"], input[name="answer"]'); }} catch(_) {{ continue; }}
if (!inp) continue;
inp.focus(); inp.value = answer;
inp.dispatchEvent(new Event('input', {{bubbles: true}}));
inp.dispatchEvent(new Event('change', {{bubbles: true}}));
return true;
}}
return false;
}})({})"#,
serde_json::to_string(&text).unwrap_or_else(|_| "\"\"".into())
),
)
.await
.and_then(|r| r.into_value::<bool>().map_err(Into::into))
.unwrap_or(false);
if !typed_via_pierce {
if let Ok(input) = page
.find_element("input[type=text], textarea, .rc-response-input")
.await
{
input.click().await.ok();
input.type_str(&text).await.ok();
}
}
let success = confidence > 0.5 && !text.is_empty();
let cookies = if success {
crate::cookies::capture_from_page(page)
.await
.unwrap_or_default()
} else {
Vec::new()
};
Ok(CaptchaSolveResult {
solution: text.clone(),
confidence,
method: SolveMethod::VisionLLM,
time_ms: t0.elapsed().as_millis() as u64,
success,
screenshot: None,
cookies,
verified_outcome: None,
})
}
_ => {
let prompt = Self::click_target_prompt("the CAPTCHA checkbox or submit button");
let raw = vlm_query(
&self.client,
self.provider,
&self.endpoint,
&self.model,
self.api_key.as_deref(),
&image_b64,
&prompt,
self.config.vlm_http_timeout_ms,
)
.await?;
let json_str = extract_json(&raw).unwrap_or(&raw);
let val: serde_json::Value =
serde_json::from_str(json_str).unwrap_or(serde_json::Value::Null);
let x = val["x"].as_f64().unwrap_or(0.0);
let y = val["y"].as_f64().unwrap_or(0.0);
let confidence = val["confidence"].as_f64().unwrap_or(0.0) as f32;
let viewport = page
.evaluate("window.innerWidth + ',' + window.innerHeight")
.await?
.into_value::<String>()
.unwrap_or_default();
let mut parts = viewport.split(',');
let vw: f64 = parts.next().and_then(|s| s.parse().ok()).unwrap_or(1920.0);
let vh: f64 = parts.next().and_then(|s| s.parse().ok()).unwrap_or(1080.0);
if x > 0.0 && y > 0.0 && x < vw && y < vh {
crate::behavior::click_realistic(page, x, y).await?;
}
let success = confidence > 0.5;
let cookies = if success {
crate::cookies::capture_from_page(page)
.await
.unwrap_or_default()
} else {
Vec::new()
};
Ok(CaptchaSolveResult {
solution: format!("click:{},{}:{:.2}", x as i64, y as i64, confidence),
confidence,
method: SolveMethod::VisionLLM,
time_ms: t0.elapsed().as_millis() as u64,
success,
screenshot: None,
cookies,
verified_outcome: None,
})
}
}
}
}
#[cfg(test)]
#[path = "vlm/tests.rs"]
mod tests;