Tutoriales de API

Resolver CAPTCHA con PowerShell y la API de CaptchaAI en Windows

Si tu script de PowerShell se detiene delante de un CAPTCHA, no necesitas instalar nada: Invoke-RestMethod ya habla el HTTP que espera la API de CaptchaAI. Envías la tarea a in.php, sondeas res.php y recibes el token. Ese es todo el mecanismo.

Lo armamos por capas: dos funciones base, un envoltorio por tipo (reCAPTCHA v2 y v3, Turnstile, imagen/OCR) y, al final, un módulo, ejecución en paralelo y el Programador de tareas. Todo corre en PowerShell 5.1 y 7+.


Por qué PowerShell encaja bien con la resolución de CAPTCHA

  • Viene con Windows: PowerShell 5.1 está de fábrica, sin dependencias que aprobar.
  • Invoke-RestMethod parsea el JSON solo: la respuesta llega como objeto, no como texto.
  • Programador de tareas nativo: se agenda igual que cualquier otro script.
  • Encadenable: el token entra directo en el siguiente paso de tu pipeline.
  • Multiplataforma: PowerShell 7+ corre en Linux y macOS, así que el script viaja a tus runners de CI.

Por API, CaptchaAI cubre reCAPTCHA v2 (invisible y Enterprise incluidas) y v3, Cloudflare Turnstile y Challenge, GeeTest v3, imagen, OCR y cuadrícula. CaptchaFox, Friendly Captcha y Lemin están en beta. hCaptcha y FunCaptcha (Arkose Labs) no son compatibles; GeeTest v4 figura como próximamente.


Requisitos previos

  • PowerShell 5.1 (Windows) o PowerShell 7+ (multiplataforma).
  • Una clave API de CaptchaAI (consíguela aquí).
  • Ningún módulo adicional: todo se resuelve con cmdlets integrados.

Si es tu primera integración, ejecuta el ciclo enviar-sondear a mano una vez: ver qué devuelve cada endpoint ahorra depuración después.


Las dos funciones base del solver

Todo lo demás se apoya en estas dos piezas: una envía la tarea, la otra sondea hasta que el resultado está listo.

Enviar la tarea a in.php

function Submit-CaptchaTask {
    param(
        [Parameter(Mandatory)]
        [string]$ApiKey,

        [Parameter(Mandatory)]
        [hashtable]$TaskParams
    )

    $body = @{
        key  = $ApiKey
        json = 1
    } + $TaskParams

    $response = Invoke-RestMethod -Uri "https://ocr.captchaai.com/in.php" `
        -Method Post `
        -Body $body `
        -ContentType "application/x-www-form-urlencoded"

    if ($response.status -ne 1) {
        throw "Submit failed: $($response.request)"
    }

    return $response.request
}

Fíjate en + $TaskParams: al fusionar hashtables, cada tipo aporta solo sus parámetros y la autenticación queda en un único sitio.

Consultar el resultado en res.php

function Get-CaptchaResult {
    param(
        [Parameter(Mandatory)]
        [string]$ApiKey,

        [Parameter(Mandatory)]
        [string]$TaskId,

        [int]$MaxWaitSeconds = 300,
        [int]$PollIntervalSeconds = 5
    )

    $deadline = (Get-Date).AddSeconds($MaxWaitSeconds)

    while ((Get-Date) -lt $deadline) {
        Start-Sleep -Seconds $PollIntervalSeconds

        $response = Invoke-RestMethod -Uri "https://ocr.captchaai.com/res.php" `
            -Method Get `
            -Body @{
                key    = $ApiKey
                action = "get"
                id     = $TaskId
                json   = 1
            }

        if ($response.request -eq "CAPCHA_NOT_READY") {
            Write-Verbose "Waiting for solution..."
            continue
        }

        if ($response.status -ne 1) {
            throw "Solve failed: $($response.request)"
        }

        return $response.request
    }

    throw "Timeout: CAPTCHA not solved within $MaxWaitSeconds seconds"
}

La función espera antes del primer sondeo, no después: pedirlo de inmediato solo devuelve CAPCHA_NOT_READY y gasta una llamada. Un intervalo de 5 segundos con un límite de 300 segundos es razonable para reCAPTCHA; para Turnstile puedes bajarlo.


reCAPTCHA v2

El caso más común: tomas el sitekey del atributo data-sitekey, lo envías con la URL y recibes el token g-recaptcha-response.

function Solve-RecaptchaV2 {
    param(
        [Parameter(Mandatory)]
        [string]$ApiKey,

        [Parameter(Mandatory)]
        [string]$SiteUrl,

        [Parameter(Mandatory)]
        [string]$SiteKey
    )

    Write-Host "Submitting reCAPTCHA v2 task..."
    $taskId = Submit-CaptchaTask -ApiKey $ApiKey -TaskParams @{
        method    = "userrecaptcha"
        googlekey = $SiteKey
        pageurl   = $SiteUrl
    }
    Write-Host "Task ID: $taskId"

    Write-Host "Polling for solution..."
    $token = Get-CaptchaResult -ApiKey $ApiKey -TaskId $taskId
    Write-Host "Solved! Token: $($token.Substring(0, [Math]::Min(50, $token.Length)))..."

    return $token
}

# Usage
$apiKey = "YOUR_API_KEY"
$token = Solve-RecaptchaV2 `
    -ApiKey $apiKey `
    -SiteUrl "https://staging.example.com/qa-login" `
    -SiteKey "6Le-wvkSAAAAAPBMRTvw0Q4Muexq9bi0DJwx_mJ-"

Las variantes invisible, callback y Enterprise usan el mismo userrecaptcha: cambian los parámetros, no la función.


Cloudflare Turnstile

Turnstile usa el mismo ciclo con el método turnstile, pero el token viaja en el campo cf-turnstile-response, no en el de reCAPTCHA. Es el fallo más frecuente al reutilizar un script.

function Solve-Turnstile {
    param(
        [Parameter(Mandatory)]
        [string]$ApiKey,

        [Parameter(Mandatory)]
        [string]$SiteUrl,

        [Parameter(Mandatory)]
        [string]$SiteKey
    )

    $taskId = Submit-CaptchaTask -ApiKey $ApiKey -TaskParams @{
        method  = "turnstile"
        key     = $SiteKey
        pageurl = $SiteUrl
    }

    return Get-CaptchaResult -ApiKey $ApiKey -TaskId $taskId
}

# Usage
$token = Solve-Turnstile `
    -ApiKey "YOUR_API_KEY" `
    -SiteUrl "https://example.com/form" `
    -SiteKey "0x4AAAAAAAB5..."

Cloudflare Challenge se resuelve igual con su propio método: un script que ya maneja Turnstile necesita pocos cambios.


reCAPTCHA v3

reCAPTCHA v3 no muestra desafío: devuelve una puntuación. Además del sitekey debes enviar la action que declara la página, porque el backend la compara al validar.

function Solve-RecaptchaV3 {
    param(
        [Parameter(Mandatory)]
        [string]$ApiKey,

        [Parameter(Mandatory)]
        [string]$SiteUrl,

        [Parameter(Mandatory)]
        [string]$SiteKey,

        [string]$Action = "verify",
    )

    $taskId = Submit-CaptchaTask -ApiKey $ApiKey -TaskParams @{
        method    = "userrecaptcha"
        googlekey = $SiteKey
        pageurl   = $SiteUrl
        version   = "v3"
        action    = $Action
    }

    return Get-CaptchaResult -ApiKey $ApiKey -TaskId $taskId
}

CAPTCHA de imagen y OCR

Para los CAPTCHA clásicos de texto sobre imagen el método es base64: lees el archivo, lo codificas y lo que vuelve es el texto.

function Solve-ImageCaptcha {
    param(
        [Parameter(Mandatory)]
        [string]$ApiKey,

        [Parameter(Mandatory)]
        [string]$ImagePath
    )

    if (-not (Test-Path $ImagePath)) {
        throw "Image file not found: $ImagePath"
    }

    $imageBytes = [System.IO.File]::ReadAllBytes($ImagePath)
    $base64 = [Convert]::ToBase64String($imageBytes)

    $taskId = Submit-CaptchaTask -ApiKey $ApiKey -TaskParams @{
        method = "base64"
        body   = $base64
    }

    return Get-CaptchaResult -ApiKey $ApiKey -TaskId $taskId
}

# Usage
$text = Solve-ImageCaptcha -ApiKey "YOUR_API_KEY" -ImagePath "C:\captcha.png"
Write-Host "CAPTCHA text: $text"

Cuando la imagen está en una URL

function Solve-ImageCaptchaFromUrl {
    param(
        [Parameter(Mandatory)]
        [string]$ApiKey,

        [Parameter(Mandatory)]
        [string]$ImageUrl
    )

    $imageBytes = (Invoke-WebRequest -Uri $ImageUrl).Content
    $base64 = [Convert]::ToBase64String($imageBytes)

    $taskId = Submit-CaptchaTask -ApiKey $ApiKey -TaskParams @{
        method = "base64"
        body   = $base64
    }

    return Get-CaptchaResult -ApiKey $ApiKey -TaskId $taskId
}

Descarga la imagen en la misma sesión HTTP que sirvió el formulario: muchos portales lo regeneran por cookie, y desde otra conexión resolverás una imagen que ya no corresponde.


Un módulo reutilizable en lugar de copiar funciones

Cuando la misma lógica aparece en varios scripts, empaquétala. Guarda esto como CaptchaAI.psm1:

class CaptchaAISolver {
    [string]$ApiKey
    [string]$BaseUrl = "https://ocr.captchaai.com"
    [int]$PollInterval = 5
    [int]$MaxWait = 300

    CaptchaAISolver([string]$apiKey) {
        $this.ApiKey = $apiKey
    }

    [string] SolveRecaptchaV2([string]$siteUrl, [string]$siteKey) {
        return $this.Solve(@{
            method    = "userrecaptcha"
            googlekey = $siteKey
            pageurl   = $siteUrl
        })
    }

    [string] SolveTurnstile([string]$siteUrl, [string]$siteKey) {
        return $this.Solve(@{
            method  = "turnstile"
            key     = $siteKey
            pageurl = $siteUrl
        })
    }

    [string] SolveImage([string]$imagePath) {
        $bytes = [System.IO.File]::ReadAllBytes($imagePath)
        $base64 = [Convert]::ToBase64String($bytes)
        return $this.Solve(@{
            method = "base64"
            body   = $base64
        })
    }

    [double] GetBalance() {
        $response = Invoke-RestMethod -Uri "$($this.BaseUrl)/res.php" `
            -Body @{ key = $this.ApiKey; action = "getbalance"; json = 1 }
        return [double]$response.request
    }

    hidden [string] Solve([hashtable]$params) {
        $taskId = $this.Submit($params)
        return $this.Poll($taskId)
    }

    hidden [string] Submit([hashtable]$params) {
        $body = @{ key = $this.ApiKey; json = 1 } + $params
        $response = Invoke-RestMethod -Uri "$($this.BaseUrl)/in.php" `
            -Method Post -Body $body
        if ($response.status -ne 1) { throw "Submit: $($response.request)" }
        return $response.request
    }

    hidden [string] Poll([string]$taskId) {
        $deadline = (Get-Date).AddSeconds($this.MaxWait)
        while ((Get-Date) -lt $deadline) {
            Start-Sleep -Seconds $this.PollInterval
            $response = Invoke-RestMethod -Uri "$($this.BaseUrl)/res.php" `
                -Body @{ key = $this.ApiKey; action = "get"; id = $taskId; json = 1 }
            if ($response.request -eq "CAPCHA_NOT_READY") { continue }
            if ($response.status -ne 1) { throw "Solve: $($response.request)" }
            return $response.request
        }
        throw "Timeout"
    }
}

# Export
Export-ModuleMember

Usar el módulo

using module .\CaptchaAI.psm1

$solver = [CaptchaAISolver]::new("YOUR_API_KEY")

# Check balance
$balance = $solver.GetBalance()
Write-Host "Balance: `$$balance"

# Solve reCAPTCHA v2
$token = $solver.SolveRecaptchaV2("https://staging.example.com/qa-login", "SITEKEY")
Write-Host "Token: $($token.Substring(0, 50))..."

GetBalance() es útil al arrancar: comprobar el saldo antes de lanzar cientos de tareas evita quedarse sin fondos a mitad de ejecución.


Enviar el formulario con el token resuelto

El token hay que devolverlo al formulario en el campo que el sitio espera, g-recaptcha-response para reCAPTCHA.

function Submit-FormWithToken {
    param(
        [string]$Url,
        [string]$Token,
        [hashtable]$FormData
    )

    $body = $FormData + @{
        "g-recaptcha-response" = $Token
    }

    $response = Invoke-WebRequest -Uri $Url `
        -Method Post `
        -Body $body `
        -ContentType "application/x-www-form-urlencoded"

    return $response
}

# Usage
$token = Solve-RecaptchaV2 -ApiKey "YOUR_API_KEY" `
    -SiteUrl "https://staging.example.com/qa-login" `
    -SiteKey "SITEKEY"

$result = Submit-FormWithToken `
    -Url "https://staging.example.com/qa-login" `
    -Token $token `
    -FormData @{
        username = "[email protected]"
        password = "password"
    }

Write-Host "Response: $($result.StatusCode)"

Los tokens caducan en minutos: resuelve el CAPTCHA justo antes de enviar el formulario, no al inicio de una secuencia larga.


Resolver varias tareas en paralelo

Aquí entra el modelo de precios. CaptchaAI factura por threads concurrentes con resoluciones ilimitadas al mes: los trabajos simultáneos deberían respetar los threads de tu plan. BASIC ($15/mes) incluye 5, STANDARD ($30/mes) 15 y ADVANCE ($90/mes) 50. El exceso espera turno.

$apiKey = "YOUR_API_KEY"

$tasks = @(
    @{ Url = "https://site-a.com"; Key = "SITEKEY_A" },
    @{ Url = "https://site-b.com"; Key = "SITEKEY_B" },
    @{ Url = "https://site-c.com"; Key = "SITEKEY_C" }
)

$jobs = $tasks | ForEach-Object {
    $task = $_
    Start-Job -ScriptBlock {
        param($ApiKey, $Url, $SiteKey)

        $taskId = (Invoke-RestMethod -Uri "https://ocr.captchaai.com/in.php" -Method Post -Body @{
            key = $ApiKey; json = 1; method = "userrecaptcha"
            googlekey = $SiteKey; pageurl = $Url
        }).request

        $deadline = (Get-Date).AddSeconds(300)
        while ((Get-Date) -lt $deadline) {
            Start-Sleep -Seconds 5
            $result = Invoke-RestMethod -Uri "https://ocr.captchaai.com/res.php" -Body @{
                key = $ApiKey; action = "get"; id = $taskId; json = 1
            }
            if ($result.request -ne "CAPCHA_NOT_READY" -and $result.status -eq 1) {
                return @{ Url = $Url; Token = $result.request }
            }
        }
        return @{ Url = $Url; Error = "Timeout" }
    } -ArgumentList $apiKey, $task.Url, $task.Key
}

# Wait and collect results
$results = $jobs | Wait-Job | Receive-Job
$results | ForEach-Object {
    if ($_.Token) {
        Write-Host "$($_.Url): $($_.Token.Substring(0, 50))..."
    } else {
        Write-Host "$($_.Url): $($_.Error)" -ForegroundColor Red
    }
}
$jobs | Remove-Job

Reintentos con criterio

No todos los errores merecen reintento. ERROR_NO_SLOT_AVAILABLE y ERROR_CAPTCHA_UNSOLVABLE son transitorios y se reintentan con retroceso exponencial; una clave inválida no mejora por insistir.

function Solve-WithRetry {
    param(
        [Parameter(Mandatory)]
        [string]$ApiKey,

        [Parameter(Mandatory)]
        [hashtable]$TaskParams,

        [int]$MaxRetries = 3
    )

    $retryableErrors = @(
        "ERROR_NO_SLOT_AVAILABLE",
        "ERROR_CAPTCHA_UNSOLVABLE"
    )

    for ($attempt = 0; $attempt -le $MaxRetries; $attempt++) {
        if ($attempt -gt 0) {
            $delay = [Math]::Pow(2, $attempt) + (Get-Random -Maximum 3)
            Write-Host "Retry $attempt/$MaxRetries after $($delay)s..."
            Start-Sleep -Seconds $delay
        }

        try {
            $taskId = Submit-CaptchaTask -ApiKey $ApiKey -TaskParams $TaskParams
            $result = Get-CaptchaResult -ApiKey $ApiKey -TaskId $taskId
            return $result
        }
        catch {
            $errorMsg = $_.Exception.Message
            $isRetryable = $retryableErrors | Where-Object { $errorMsg -like "*$_*" }

            if (-not $isRetryable -or $attempt -eq $MaxRetries) {
                throw
            }
            Write-Warning "Retryable error: $errorMsg"
        }
    }
}

Escenario: vigilar un trámite público desde el Programador de tareas

Un caso que se repite en equipos de habla hispana: un portal público —una cita previa, un trámite fiscal— protegido con reCAPTCHA, y un QA que debe comprobar cada mañana que el formulario responde. Se agenda una vez y corre solo.

# Create a scheduled task that runs CAPTCHA automation daily
$action = New-ScheduledTaskAction `
    -Execute "powershell.exe" `
    -Argument "-ExecutionPolicy Bypass -File C:\Scripts\captcha-automation.ps1"

$trigger = New-ScheduledTaskTrigger -Daily -At "08:00"

Register-ScheduledTask `
    -TaskName "CaptchaAutomation" `
    -Action $action `
    -Trigger $trigger `
    -Description "Run daily CAPTCHA automation con CaptchaAI"

Dos advertencias. Guarda la clave API fuera del .ps1 que va al repositorio. Y automatiza solo flujos sobre los que tengas permiso, respetando los términos del portal y la normativa de protección de datos aplicable (GDPR y LOPDGDD en España, LFPDPPP en México).

El costo también pesa: para una agencia que factura en moneda local volátil, una cuota mensual fija en USD se presupuesta mejor que un gasto por resolución que sigue al tráfico.


Errores frecuentes y cómo salir de ellos

Error Causa Solución
ERROR_WRONG_USER_KEY Clave no válida Verifícala en tu panel
ERROR_ZERO_BALANCE Sin saldo Recarga la cuenta
Invoke-RestMethod: SSL/TLS TLS incompatible Añade [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
The response content cannot be parsed Respuesta no JSON Usa Invoke-WebRequest y parsea a mano
Error de Execution policy Script bloqueado Set-ExecutionPolicy -Scope CurrentUser RemoteSigned
Cannot convert to double Saldo no parseable Usa [double]::Parse($response.request)

Si el token llega pero el sitio lo rechaza, revisa que el pageurl coincida con la URL del formulario.


Preguntas frecuentes

¿Qué versión de PowerShell necesito?

PowerShell 5.1, la que ya trae Windows, basta. Invoke-RestMethod e Invoke-WebRequest se comportan igual en 7+, donde además corre en Linux y macOS.

¿Cuántos threads necesito para mi volumen?

De cuántos CAPTCHA tengas en vuelo a la vez, no de cuántos resuelvas al mes: son ilimitados dentro del plan. En secuencial, BASIC ($15/mes, 5 threads) sobra; en paralelo, mira STANDARD ($30/mes, 15 threads) o ADVANCE ($90/mes, 50 threads).

¿Dónde guardo la clave API en un script agendado?

En una variable de entorno o en el Administrador de credenciales de Windows, y la lees con $env:CAPTCHAAI_KEY. Una clave dentro del .ps1 acaba en el repositorio y en las copias de seguridad.

¿Por qué mi token es válido y el formulario lo rechaza igual?

Casi siempre por desajuste de contexto: el pageurl no coincide con la URL del formulario, el token caducó, o va en el campo de otro tipo.

¿Sirve esto para hCaptcha?

No. CaptchaAI no resuelve hCaptcha ni FunCaptcha (Arkose Labs), y GeeTest v4 figura como próximamente. Para GeeTest v3, que sí es compatible, solo cambia el método en Submit-CaptchaTask.


Guías relacionadas


Tu automatización no tiene por qué pararse en un CAPTCHA: consigue tu clave API y pega la primera función en tu script.

Los comentarios están deshabilitados para este artículo.