use super::*;
use crate::captcha_detect::DetectedCaptcha;
use std::time::Instant;
const POW_SOLVE_JS: &str = r#"
(async (maxMs) => {
const t0 = Date.now();
const deadline = t0 + maxMs;
/* SHA-256 helper using SubtleCrypto. Returns lower-case hex. */
const sha256Hex = async (str) => {
const buf = new TextEncoder().encode(str);
const hash = await crypto.subtle.digest('SHA-256', buf);
return Array.from(new Uint8Array(hash))
.map(b => b.toString(16).padStart(2, '0')).join('');
};
/* ALTCHA */
const altcha = document.querySelector('altcha-widget, .altcha, [data-altcha]');
if (altcha) {
try {
const algorithm = (altcha.getAttribute('algorithm') || 'SHA-256').toLowerCase();
const challenge = altcha.getAttribute('challenge')
|| altcha.dataset.challenge
|| (altcha.dataset.json && JSON.parse(altcha.dataset.json).challenge);
const salt = altcha.getAttribute('salt')
|| altcha.dataset.salt
|| (altcha.dataset.json && JSON.parse(altcha.dataset.json).salt);
const maxnumber = parseInt(altcha.getAttribute('maxnumber')
|| altcha.dataset.maxnumber || '1000000', 10);
if (challenge && salt && algorithm === 'sha-256') {
for (let n = 0; n <= maxnumber; n++) {
if (Date.now() > deadline) break;
const h = await sha256Hex(salt + n);
if (h === challenge) {
const payload = btoa(JSON.stringify({
algorithm: 'SHA-256', challenge, salt, number: n
}));
const inp = document.querySelector('input[name="altcha"]');
if (inp) inp.value = payload;
return payload;
}
}
}
} catch (e) { /* fall through */ }
}
/* Friendly Captcha */
const frc = document.querySelector('.frc-captcha, frc-captcha, [data-frc-captcha]');
if (frc) {
const inp = document.querySelector('input[name="frc-captcha-solution"]');
/* Friendly's own worker usually finishes within 1-3s.
Poll its solution field rather than reimplement the
protocol — their puzzle format is binary and changes. */
while (Date.now() < deadline) {
if (inp && inp.value && inp.value !== '.UNSTARTED' && inp.value !== '.UNFINISHED') {
return inp.value;
}
await new Promise(r => setTimeout(r, 200));
}
}
/* MCaptcha */
const mcap = document.querySelector('.mcaptcha__widget, mcaptcha, [data-mcaptcha]');
if (mcap) {
const inp = document.querySelector('input[name="mcaptcha__token"]');
while (Date.now() < deadline) {
if (inp && inp.value && inp.value.length > 10) {
return inp.value;
}
await new Promise(r => setTimeout(r, 200));
}
}
/* Cap.dev */
const cap = document.querySelector('cap-widget, .cap-widget, [data-cap]');
if (cap) {
const inp = document.querySelector('input[name*="cap-token"], input[name*="cap_token"]');
while (Date.now() < deadline) {
if (inp && inp.value && inp.value.length > 10) {
return inp.value;
}
await new Promise(r => setTimeout(r, 200));
}
}
return null;
})(15000)
"#;
pub struct PowCaptchaSolver {
max_wait_ms: u64,
}
impl Default for PowCaptchaSolver {
fn default() -> Self {
Self::new()
}
}
impl PowCaptchaSolver {
pub fn new() -> Self {
Self {
max_wait_ms: 15_000,
}
}
pub fn with_max_wait_ms(mut self, ms: u64) -> Self {
self.max_wait_ms = ms;
self
}
}
#[async_trait]
impl CaptchaSolver for PowCaptchaSolver {
fn name(&self) -> &'static str {
"PowCaptchaSolver"
}
fn method(&self) -> SolveMethod {
SolveMethod::BehavioralBypass
}
fn supports(&self, kind: &DetectedCaptcha) -> bool {
match kind {
DetectedCaptcha::PowCaptcha => true,
DetectedCaptcha::Custom(name) => matches!(
name.as_str(),
"friendly_captcha" | "altcha" | "mcaptcha" | "cap_dev" | "cap"
),
_ => false,
}
}
async fn solve(&self, page: &Page, _info: &CaptchaInfo) -> Result<CaptchaSolveResult> {
let t0 = Instant::now();
let js = POW_SOLVE_JS.replace("(15000)", &format!("({})", self.max_wait_ms));
let raw = page
.evaluate(js)
.await
.map_err(|e| anyhow!("pow solver evaluate failed: {e}"))?;
let token: Option<String> = raw.into_value().ok().flatten();
let elapsed = t0.elapsed().as_millis() as u64;
if let Some(token) = token.filter(|t| !t.is_empty()) {
let cookies = crate::cookies::capture_from_page(page)
.await
.unwrap_or_default();
return Ok(CaptchaSolveResult {
solution: token,
confidence: 1.0,
method: self.method(),
time_ms: elapsed,
success: true,
screenshot: None,
cookies,
});
}
Ok(CaptchaSolveResult::failure(self.method(), elapsed))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::captcha_detect::DetectedCaptcha;
#[test]
fn defaults_are_sane() {
let s = PowCaptchaSolver::new();
assert_eq!(s.max_wait_ms, 15_000);
}
#[test]
fn builders_override_each_field() {
let s = PowCaptchaSolver::new().with_max_wait_ms(5_000);
assert_eq!(s.max_wait_ms, 5_000);
}
#[test]
fn supports_pow_and_known_custom_vendors_only() {
let s = PowCaptchaSolver::new();
assert!(s.supports(&DetectedCaptcha::PowCaptcha));
assert!(s.supports(&DetectedCaptcha::Custom("friendly_captcha".into())));
assert!(s.supports(&DetectedCaptcha::Custom("altcha".into())));
assert!(s.supports(&DetectedCaptcha::Custom("mcaptcha".into())));
assert!(!s.supports(&DetectedCaptcha::Custom("datadome".into())));
assert!(!s.supports(&DetectedCaptcha::Custom("perimeterx_human".into())));
assert!(!s.supports(&DetectedCaptcha::Turnstile));
}
#[test]
fn name_and_method_stable() {
let s = PowCaptchaSolver::new();
assert_eq!(s.name(), "PowCaptchaSolver");
assert_eq!(s.method(), SolveMethod::BehavioralBypass);
}
#[test]
fn pow_solve_js_covers_all_supported_vendors() {
for needle in [
"altcha",
"frc-captcha",
"mcaptcha",
"cap-widget",
"SubtleCrypto",
"SHA-256",
"input[name=\"altcha\"]",
"input[name=\"frc-captcha-solution\"]",
] {
assert!(
POW_SOLVE_JS.contains(needle)
|| POW_SOLVE_JS.to_lowercase().contains(&needle.to_lowercase()),
"POW_SOLVE_JS must cover: {needle}"
);
}
}
}