Integrations

Selenium Grid + CaptchaAI: распределенное решение CAPTCHA

Узкое место распределённой автоматизации почти никогда не в самих браузерах. Вы поднимаете десять узлов Selenium Grid, получаете 30–50 параллельных сессий — и обнаруживаете, что половина из них стоит в ожидании ответа CaptchaAI, потому что тариф выдаёт меньше потоков, чем Grid выдаёт слотов. Правило простое: число воркеров подбирают не по количеству узлов, а по минимуму из свободных слотов Grid и потоков в вашем плане.

Дальше — рабочая схема целиком: Grid 4 в Docker, единый клиент CaptchaAI для всех узлов, параллельный запуск задач, контроль ёмкости, автомасштабирование в Kubernetes и тот же клиент на Java. Решение CAPTCHA здесь — штатный шаг интеграционного тестирования и парсинга доступных вам страниц.


Как устроена связка Grid и CaptchaAI

┌─────────────┐     ┌──────────────┐     ┌──────────────┐
│  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)

Ключевая деталь: узлы Grid не координируют решение капчи между собой. Каждая сессия идёт в один https://ocr.captchaai.com с одним API-ключом, а очередь держит сторона API. Масштабирование здесь двухконтурное: ёмкость браузеров и ёмкость решателя — разные величины.

Как подобрать тариф под размер Grid

CaptchaAI тарифицируется по потокам — по числу одновременно решаемых задач, без лимита решений внутри потока. Считайте по слотам браузеров, а не по машинам.

Размер стенда Слотов Grid Подходящий тариф
2–3 узла для отладки 10–15 STANDARD ($30/мес, 15 потоков)
10 узлов по 5 сессий 50 ADVANCE ($90/мес, 50 потоков)
Ночная регрессия на сотню браузеров ~100 PREMIUM ($170/мес, 100 потоков)

Цены указаны в USD и не пересчитываются в местные валюты. Фиксированная месячная стоимость за поток предсказуемее оплаты за каждое решение.


Разверните Selenium Grid 4 в Docker

Compose-файл: хаб и три узла Chrome

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
docker-compose up -d

Три узла по пять сессий дают 15 слотов — достаточно, чтобы отладить интеграцию до перехода в Kubernetes. Две тонкости этой конфигурации:

  • SE_NODE_MAX_SESSIONS без SE_NODE_OVERRIDE_MAX_SESSIONS=true игнорируется: Selenium ограничивает сессии числом ядер CPU. На слабой виртуальной машине спотыкаются именно об это.
  • Один Chrome под нагрузкой занимает 300–700 МБ, то есть 15 сессий — это 6–8 ГБ только под браузеры, без учёта хаба.

Подключите CaptchaAI к сессиям Grid

Клиент ниже открывает удалённую сессию на хабе, отправляет задачу в in.php и опрашивает res.php до готовности токена. Методы разделены для reCAPTCHA v2 и Cloudflare Turnstile — оба типа доступны в общем доступе.

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()

Что здесь важно с точки зрения эксплуатации:

  • Опрос с паузой, а не цикл без задержки. Интервал 5 с и 60 итераций дают потолок ожидания в 5 мин — с запасом на пиковую нагрузку. Промежуточный ответ CAPCHA_NOT_READY — это норма, а не ошибка.
  • Токен подставляется в #g-recaptcha-response и во все поля с тем же именем. Скрытых полей на странице может быть несколько, особенно если форма рендерится фреймворком.
  • driver.quit() в блоке finally. Незакрытая сессия держит слот Grid до истечения SE_SESSION_TIMEOUT, и на длинном прогоне такие зависшие сессии съедают всю ёмкость.
  • Определение sitekey. Скрипт сначала берёт значение из описания задачи и только потом ищет [data-sitekey] в DOM. Явно заданное значение надёжнее: разметку страницы могут изменить в любой момент.

Запустите задачи параллельно

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")

Значение max_workers=15 не универсально. Берите минимум из трёх величин: свободные слоты Grid, потоки тарифа и нагрузка, которую выдерживает целевой стенд. Превышение даёт SessionNotCreated, очередь на стороне API или ограничение частоты запросов.

Сценарий из практики. Команда в Алматы гоняет ночную регрессию форм регистрации на staging-стенде: 120 сценариев, каждый с reCAPTCHA v2. На 6 узлах по 5 сессий получается 30 слотов, тариф ADVANCE ($90/мес, 50 потоков) закрывает решатель с запасом. Прогон упирается не в капчу, а в холодный старт Chrome, поэтому узлы поднимают за 10 мин до пайплайна. Если сценарии затрагивают реальные пользовательские данные, основания их обработки проверяются отдельно — 152-ФЗ «О персональных данных» для стендов в РФ, процедуры уровня GDPR для трансграничных команд. Это вопрос вашей внутренней политики, а не свойство сервиса.


Проверяйте ёмкость Grid перед прогоном

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}")

/status показывает фактическую ёмкость на момент старта, а не проектную. Вызывайте проверку перед каждым большим прогоном: узел, отвалившийся от шины событий, исчезает из ответа молча, а задачи падают с WebDriverException без внятной причины.


Типичные ошибки и что с ними делать

Проблема Причина Что сделать
SessionNotCreated Свободных слотов нет Добавить узлы или поднять SE_NODE_MAX_SESSIONS вместе с SE_NODE_OVERRIDE_MAX_SESSIONS
Тайм-аут на стороне Grid Узел перегружен Снизить число одновременных сессий на узел
WebDriverException Узел отвалился от шины событий Добавить повтор создания сессии и проверку /status
Нехватка памяти Слишком много экземпляров Chrome Выставить лимиты ресурсов и реальный максимум сессий
Долгое ожидание токена Все потоки тарифа заняты Увеличить потолок опроса, добавить повторы или перейти на тариф с большим числом потоков
Зависшие сессии Драйвер не закрыт в коде Закрывать драйвер в finally и задать SE_SESSION_TIMEOUT

Масштабируйте узлы в 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

HPA поднимает узлы по загрузке CPU, но решатель за ними не тянется автоматически: количество потоков тарифа — фиксированная величина. Если maxReplicas: 20 при трёх сессиях на под даёт 60 слотов, а тариф рассчитан на 50 потоков, часть сессий будет ждать.

Что проверить перед включением автомасштабирования

  • Потолок согласован с тарифом. maxReplicas × SE_NODE_MAX_SESSIONS не должно превышать число потоков плана.
  • Лимиты памяти под число сессий. memory: 2Gi и cpu: 1 рассчитаны на три сессии Chrome; урезать их, не снижая SE_NODE_MAX_SESSIONS, — прямой путь к OOM-kill.
  • Запас на холодный старт. Под с Chrome готов не мгновенно, поэтому HPA отстаёт от всплеска нагрузки на минуту-другую.
  • Корректное завершение сессий. Задайте terminationGracePeriodSeconds под максимальную длительность сценария, иначе снятый под теряет результат.

Тот же клиент на Java

Если тестовый контур живёт в JVM-стеке, логика не меняется: удалённая сессия через RemoteWebDriver, пул фиксированного размера и один и тот же API-ключ CaptchaAI на все потоки.

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;
    }
}

Часто задаваемые вопросы

Сколько потоков CaptchaAI нужно на 10 узлов Grid?

Считайте по слотам, а не по узлам. Десять узлов по 5 сессий — это 50 одновременных браузеров, то есть уровень ADVANCE ($90/мес, 50 потоков). Если пик держится недолго, можно взять тариф ниже и заложить очередь на стороне опроса.

Почему узел игнорирует SE_NODE_MAX_SESSIONS?

Потому что не задан SE_NODE_OVERRIDE_MAX_SESSIONS=true. Без него Selenium ограничивает число сессий количеством доступных ядер CPU, и значение переменной просто не применяется.

Что делать, если решение возвращается дольше обычного?

Сначала проверьте, не заняты ли все потоки тарифа: при полной загрузке задача ждёт освобождения потока. Увеличьте потолок опроса, добавьте повторную попытку с экспоненциальной задержкой и только потом меняйте тариф.

Можно ли смешивать Chrome, Firefox и Edge в одном Grid?

Да. Узлы разных браузеров живут в одном хабе, а вызовы к CaptchaAI от браузера не зависят — нужны только sitekey и URL страницы. Разница проявится в потреблении памяти и во времени старта сессии.

Подходит ли эта схема для GeeTest v4 или hCaptcha?

Нет. CaptchaAI не решает hCaptcha и FunCaptcha, а поддержка GeeTest v4 заявлена как «скоро» и пока недоступна. Из семейства GeeTest доступна только v3. Типы CaptchaFox (beta), Friendly Captcha (beta) и Lemin (beta) находятся в бета-статусе, и их не стоит закладывать в критичный ночной прогон.


Что почитать дальше


Подключите CaptchaAI к узлам Selenium Grid и запустите первый параллельный прогон — получите API-ключ CaptchaAI и проверьте ёмкость на одном узле, прежде чем масштабировать.

Комментарии для этой статьи отключены.