Integrations

Selenium Grid + CaptchaAI: solução distribuída de CAPTCHA

Antes de escalar a automação, a conta que importa é uma só: quantos CAPTCHAs cabem em paralelo. No Selenium Grid esse número tem dois tetos — os slots de sessão dos seus nós e as threads do seu plano na CaptchaAI — e vale sempre o menor deles. Quem ignora o segundo sobe vinte contêineres do Chrome e vê as tarefas esperando do lado da API.

Este guia alinha os dois números: Grid do Selenium 4 com Docker Compose, uma chave de API compartilhada, execução paralela em Python e Java e escalonamento no Kubernetes.


Threads da CaptchaAI: o teto real da simultaneidade

A CaptchaAI cobra por thread simultânea, não por resolução: cada plano inclui resoluções ilimitadas dentro das threads contratadas. Uma thread é um CAPTCHA em andamento e, quando ele termina, ela fica livre. É esse número, não a contagem de contêineres, que define o rendimento.

Plano Preço/mês Threads Grid equivalente
BASIC $15 5 1 nó de 5 slots
STANDARD $30 15 3 nós de 5 slots
ADVANCE $90 50 10 nós de 5 slots
PREMIUM $170 100 20 nós de 5 slots
CORPORATE $240 150 30 nós de 5 slots
ENTERPRISE $300 200 40 nós de 5 slots

Regra prática: some o SE_NODE_MAX_SESSIONS de todos os nós e compare com as threads do plano. Slots a mais são navegadores abertos esperando thread livre, ou seja, memória parada; slots a menos são threads pagas e ociosas.

Entram nessa conta reCAPTCHA v2 e v3 (inclusive Enterprise), Cloudflare Turnstile e Challenge, GeeTest v3, imagem/OCR, grade de imagens e BLS, além de CaptchaFox (beta), Friendly Captcha (beta) e Lemin (beta). hCaptcha e FunCaptcha não são suportados; o GeeTest v4 aparece apenas como "em breve".


Arquitetura: um hub, vários nós, uma chave de API

┌─────────────┐     ┌──────────────┐     ┌──────────────┐
│  Test Script │────▶│  Grid Hub    │────▶│  Node 1      │
│  (Client)    │     │  (Router)    │     │  Chrome x 5  │
└─────────────┘     └──────────────┘     └──────────────┘
                           │              ┌──────────────┐
                           ├─────────────▶│  Node 2      │
                           │              │  Chrome x 5  │
                           │              └──────────────┘
                           │              ┌──────────────┐
                           └─────────────▶│  Node 3      │
                                          │  Chrome x 5  │
                                          └──────────────┘

All nodes share ──▶ CaptchaAI API (single API key)

O hub roteia: recebe o pedido de sessão e o encaminha ao nó com slot livre. Os nós não falam com a CaptchaAI — quem chama a API é o código cliente, por HTTP. A chave fica em um lugar só e a integração independe do navegador, então Chrome, Firefox e Edge convivem no mesmo Grid.


Suba o Grid do Selenium 4 com Docker Compose

Um hub e três nós Chrome de 5 sessões: 15 slots, o teto do plano STANDARD (US$ 30/mês, 15 threads).

version: "3"
services:
  selenium-hub:
    image: selenium/hub:4.21.0
    container_name: selenium-hub
    ports:

      - "4442:4442"
      - "4443:4443"
      - "4444:4444"

  chrome-node-1:
    image: selenium/node-chrome:4.21.0
    depends_on:

      - selenium-hub
    environment:

      - SE_EVENT_BUS_HOST=selenium-hub
      - SE_EVENT_BUS_PUBLISH_PORT=4442
      - SE_EVENT_BUS_SUBSCRIBE_PORT=4443
      - SE_NODE_MAX_SESSIONS=5
      - SE_NODE_OVERRIDE_MAX_SESSIONS=true

  chrome-node-2:
    image: selenium/node-chrome:4.21.0
    depends_on:

      - selenium-hub
    environment:

      - SE_EVENT_BUS_HOST=selenium-hub
      - SE_EVENT_BUS_PUBLISH_PORT=4442
      - SE_EVENT_BUS_SUBSCRIBE_PORT=4443
      - SE_NODE_MAX_SESSIONS=5
      - SE_NODE_OVERRIDE_MAX_SESSIONS=true

  chrome-node-3:
    image: selenium/node-chrome:4.21.0
    depends_on:

      - selenium-hub
    environment:

      - SE_EVENT_BUS_HOST=selenium-hub
      - SE_EVENT_BUS_PUBLISH_PORT=4442
      - SE_EVENT_BUS_SUBSCRIBE_PORT=4443
      - SE_NODE_MAX_SESSIONS=5
      - SE_NODE_OVERRIDE_MAX_SESSIONS=true

Suba tudo:

docker-compose up -d

Em poucos segundos, http://localhost:4444 mostra os três nós registrados. Nó ausente costuma ser SE_EVENT_BUS_HOST apontando para um nome que o contêiner não resolve.


Um cliente de resolução compartilhado pelos nós

A classe abre a sessão remota, envia o desafio para in.php e consulta res.php a cada 5 segundos até sair de CAPCHA_NOT_READY.

import requests
import time
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from concurrent.futures import ThreadPoolExecutor, as_completed


class GridCaptchaSolver:
    CAPTCHAAI_URL = "https://ocr.captchaai.com"

    def __init__(self, api_key, grid_url="http://localhost:4444"):
        self.api_key = api_key
        self.grid_url = grid_url

    def create_session(self):
        """Create a new browser session on the Grid."""
        options = webdriver.ChromeOptions()
        options.add_argument("--no-sandbox")
        options.add_argument("--disable-blink-features=AutomationControlled")
        options.add_argument("--window-size=1920,1080")

        driver = webdriver.Remote(
            command_executor=self.grid_url,
            options=options,
        )
        return driver

    def solve_recaptcha_v2(self, site_url, sitekey):
        """Solve reCAPTCHA v2 via CaptchaAI API."""
        # Submit
        resp = requests.post(f"{self.CAPTCHAAI_URL}/in.php", data={
            "key": self.api_key,
            "method": "userrecaptcha",
            "googlekey": sitekey,
            "pageurl": site_url,
            "json": 1,
        })
        data = resp.json()
        if data["status"] != 1:
            raise Exception(f"Submit: {data['request']}")

        task_id = data["request"]

        # Poll
        for _ in range(60):
            time.sleep(5)
            resp = requests.get(f"{self.CAPTCHAAI_URL}/res.php", params={
                "key": self.api_key, "action": "get",
                "id": task_id, "json": 1,
            })
            data = resp.json()
            if data["request"] == "CAPCHA_NOT_READY":
                continue
            if data["status"] != 1:
                raise Exception(f"Solve: {data['request']}")
            return data["request"]

        raise Exception("Timeout")

    def solve_turnstile(self, site_url, sitekey):
        resp = requests.post(f"{self.CAPTCHAAI_URL}/in.php", data={
            "key": self.api_key, "method": "turnstile",
            "sitekey": sitekey, "pageurl": site_url, "json": 1,
        })
        data = resp.json()
        if data["status"] != 1:
            raise Exception(f"Submit: {data['request']}")

        task_id = data["request"]
        for _ in range(60):
            time.sleep(5)
            resp = requests.get(f"{self.CAPTCHAAI_URL}/res.php", params={
                "key": self.api_key, "action": "get",
                "id": task_id, "json": 1,
            })
            data = resp.json()
            if data["request"] == "CAPCHA_NOT_READY":
                continue
            if data["status"] != 1:
                raise Exception(f"Solve: {data['request']}")
            return data["request"]

        raise Exception("Timeout")

    def process_task(self, task):
        """Process a single CAPTCHA-protected task on a Grid node."""
        driver = self.create_session()

        try:
            driver.get(task["url"])
            time.sleep(2)

            # Detect sitekey
            sitekey = task.get("sitekey")
            if not sitekey:
                sitekey = driver.execute_script(
                    "return document.querySelector('[data-sitekey]')?.getAttribute('data-sitekey')"
                )

            if not sitekey:
                return {"url": task["url"], "status": "no_captcha", "data": driver.page_source[:500]}

            # Solve
            token = self.solve_recaptcha_v2(task["url"], sitekey)

            # Inject
            driver.execute_script(f"""
                document.querySelector('#g-recaptcha-response').value = '{token}';
                document.querySelectorAll('[name="g-recaptcha-response"]').forEach(
                    el => el.value = '{token}'
                );
            """)

            # Fill form and submit
            if task.get("form_data"):
                for field, value in task["form_data"].items():
                    driver.find_element(By.NAME, field).send_keys(value)

            if task.get("submit_selector"):
                driver.find_element(By.CSS_SELECTOR, task["submit_selector"]).click()
                time.sleep(3)

            return {
                "url": task["url"],
                "status": "success",
                "result_url": driver.current_url,
                "data": driver.page_source[:1000],
            }

        except Exception as e:
            return {"url": task["url"], "status": "error", "error": str(e)}

        finally:
            driver.quit()

solve_recaptcha_v2 e solve_turnstile diferem só no method e no parâmetro da sitekey (chave pública do widget): googlekey no reCAPTCHA v2, sitekey no Turnstile. Depois, process_task preenche o campo g-recaptcha-response, completa o formulário e envia. O driver.quit() no finally não é opcional: sem ele, uma exceção segura o slot até o SE_SESSION_TIMEOUT estourar e o Grid parece cheio sem estar.


Execução paralela sem estourar o plano

def run_parallel_tasks(api_key, tasks, max_workers=10):
    """Run CAPTCHA tasks in parallel across Grid nodes."""
    solver = GridCaptchaSolver(api_key)
    results = []

    with ThreadPoolExecutor(max_workers=max_workers) as executor:
        futures = {
            executor.submit(solver.process_task, task): task
            for task in tasks
        }

        for future in as_completed(futures):
            task = futures[future]
            try:
                result = future.result(timeout=600)
                results.append(result)
                print(f"[{result['status']}] {result['url']}")
            except Exception as e:
                results.append({
                    "url": task["url"],
                    "status": "exception",
                    "error": str(e),
                })

    return results


# Usage
tasks = [
    {
        "url": "https://site-a.com/form",
        "sitekey": "6Le-wvkSAAAAAPBMRTvw0Q4Muexq9bi0DJwx_mJ-",
        "form_data": {"name": "Test User", "email": "[email protected]"},
        "submit_selector": "#submit",
    },
    {
        "url": "https://site-b.com/register",
        "sitekey": "6LdKlZEpAAAAAAOQjzC2v_mJ-",
        "form_data": {"username": "testuser"},
        "submit_selector": "button[type='submit']",
    },
    # Add more tasks...
]

results = run_parallel_tasks("YOUR_API_KEY", tasks, max_workers=15)

# Summary
success = sum(1 for r in results if r["status"] == "success")
print(f"\nCompleted: {success}/{len(results)} successful")

O max_workers deve ficar abaixo do menor valor entre slots livres e threads do plano — aqui, 15 e 15. O timeout=600 existe porque carregar a página, resolver e enviar passa de um minuto em páginas pesadas; sem ele, uma sessão travada segura a thread até o fim do lote.


Cheque a capacidade do Grid antes do lote

import requests

def check_grid_status(grid_url="http://localhost:4444"):
    """Check Selenium Grid status and available nodes."""
    try:
        resp = requests.get(f"{grid_url}/status")
        data = resp.json()

        nodes = data.get("value", {}).get("nodes", [])
        total_slots = 0
        available_slots = 0

        print(f"Grid Status: {data['value']['ready']}")
        print(f"Nodes: {len(nodes)}")

        for i, node in enumerate(nodes):
            slots = node.get("slots", [])
            free = sum(1 for s in slots if not s.get("session"))
            total_slots += len(slots)
            available_slots += free
            print(f"  Node {i+1}: {free}/{len(slots)} slots available")

        print(f"Total capacity: {available_slots}/{total_slots} available")
        return available_slots

    except Exception as e:
        print(f"Grid check failed: {e}")
        return 0


# Adjust workers based on grid capacity
available = check_grid_status()
optimal_workers = min(available, 20)
print(f"Optimal workers: {optimal_workers}")

Consultar /status antes de cada execução evita o erro clássico do pipeline agendado: o lote começa com um nó ainda subindo, metade das sessões falha com SessionNotCreated e a culpa sobra para a API. Calcule o max_workers a partir dos slots livres.


Escalonamento automático no Kubernetes

# selenium-grid-k8s.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: selenium-chrome-node
spec:
  replicas: 5
  selector:
    matchLabels:
      app: selenium-chrome
  template:
    metadata:
      labels:
        app: selenium-chrome
    spec:
      containers:

        - name: chrome
          image: selenium/node-chrome:4.21.0
          env:

            - name: SE_EVENT_BUS_HOST
              value: selenium-hub

            - name: SE_EVENT_BUS_PUBLISH_PORT
              value: "4442"

            - name: SE_EVENT_BUS_SUBSCRIBE_PORT
              value: "4443"

            - name: SE_NODE_MAX_SESSIONS
              value: "3"
          resources:
            limits:
              memory: "2Gi"
              cpu: "1"
            requests:
              memory: "1Gi"
              cpu: "500m"
---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: chrome-node-hpa
spec:
  scaleRef:
    apiVersion: apps/v1
    kind: Deployment
    name: selenium-chrome-node
  minReplicas: 2
  maxReplicas: 20
  metrics:

    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: 70

Com SE_NODE_MAX_SESSIONS: "3" e o HPA entre 2 e 20 réplicas, a capacidade vai de 6 a 60 slots. Faça a conta do pior caso: 60 slots pedem no mínimo o ADVANCE (US$ 90/mês, 50 threads) e, com folga, o PREMIUM (US$ 170/mês, 100 threads). O maxReplicas é o seu limite de custo.


Integração em Java

Quem já mantém a suíte em JUnit não precisa de um segundo runtime.

import org.openqa.selenium.WebDriver;
import org.openqa.selenium.remote.RemoteWebDriver;
import org.openqa.selenium.chrome.ChromeOptions;
import java.net.URL;
import java.net.http.*;
import java.net.URI;
import java.util.concurrent.*;

public class GridCaptchaSolver {
    private final String apiKey;
    private final String gridUrl;
    private final HttpClient httpClient;

    public GridCaptchaSolver(String apiKey, String gridUrl) {
        this.apiKey = apiKey;
        this.gridUrl = gridUrl;
        this.httpClient = HttpClient.newHttpClient();
    }

    public WebDriver createSession() throws Exception {
        ChromeOptions options = new ChromeOptions();
        options.addArguments("--no-sandbox", "--window-size=1920,1080");
        return new RemoteWebDriver(new URL(gridUrl), options);
    }

    public List<Map<String, String>> runParallel(
        List<Map<String, String>> tasks, int workers
    ) throws Exception {
        ExecutorService executor = Executors.newFixedThreadPool(workers);
        List<Future<Map<String, String>>> futures = new ArrayList<>();

        for (Map<String, String> task : tasks) {
            futures.add(executor.submit(() -> processTask(task)));
        }

        List<Map<String, String>> results = new ArrayList<>();
        for (Future<Map<String, String>> future : futures) {
            results.add(future.get(600, TimeUnit.SECONDS));
        }

        executor.shutdown();
        return results;
    }
}

newFixedThreadPool(workers) faz o papel do ThreadPoolExecutor, e future.get(600, TimeUnit.SECONDS) é o mesmo limite por tarefa.


Cenário: regressão noturna de QA em sa-east-1

Um time em São Paulo mantém 300 formulários em staging.example.com e roda a regressão toda madrugada. Hospedar hub e nós em uma região próxima, como sa-east-1, reduz o RTT entre script, Grid e aplicação de teste — a chamada à CaptchaAI é mais uma requisição HTTP no caminho. Com o ADVANCE (US$ 90/mês, 50 threads) e 10 nós de 5 slots, as 300 tarefas saem em lotes de 50; meça o tempo médio no seu próprio ambiente antes de prometer a janela.

Os dados enviados são fictícios e ficam restritos ao staging. Se o pipeline capturar dado pessoal real, as obrigações da LGPD (RGPD, em Portugal) passam a valer sobre esses registros, inclusive sobre o page_source guardado no resultado. E automatize apenas aplicações que você opera ou tem autorização por escrito para testar.


Solução de problemas

Problema Causa provável Como corrigir
SessionNotCreated Nenhum slot livre Mais nós ou SE_NODE_MAX_SESSIONS maior
Timeout ao criar a sessão Nó sem CPU ou memória Reduza as sessões por nó
WebDriverException Nó saiu do Grid Retentativa na criação da sessão
Contêiner encerrado Chrome demais por nó limits de memória e teto de sessões
Resolução estourando o tempo Threads do plano ocupadas Timeout de polling maior, backoff, plano acima
Sessões obsoletas Limpeza atrasada Ajuste SE_SESSION_TIMEOUT e garanta driver.quit()

Perguntas frequentes

Preciso de um plano maior só porque adicionei nós ao Grid?

Depende de quantas sessões esses nós usam ao mesmo tempo: nó ocioso não consome thread. Some os slots que rodam de fato em paralelo e compare com as threads do plano.

O que acontece quando as tarefas passam do número de threads?

As excedentes esperam uma thread ser liberada. Não há cobrança extra por resolução nem taxa por tipo de CAPTCHA — o efeito é só tempo de fila.

hCaptcha e FunCaptcha funcionam nos nós do Grid?

Não. Nenhum dos dois é suportado, e o GeeTest v4 segue apenas como "em breve". Pelo Grid você resolve reCAPTCHA v2/v3, Turnstile, Cloudflare Challenge, GeeTest v3, imagem/OCR, grade de imagens e BLS.

Crio uma sessão por tarefa ou reaproveito a mesma?

Uma por tarefa é o padrão seguro: cookies e estado não vazam entre execuções. Reaproveitar só compensa quando as tarefas compartilham domínio e sessão autenticada.


Guias relacionados


Escale a resolução de CAPTCHA entre navegadores distribuídos — obtenha sua chave da CaptchaAI e conecte o Selenium Grid ao seu pipeline.

Os comentários estão desativados para este artigo.