Tích Hợp

Selenium Grid + CaptchaAI: giải CAPTCHA phân tán

Một máy chỉ gánh được khoảng năm phiên Chrome trước khi RAM và CPU bắt đầu nghẹt. Selenium Grid gỡ đúng nút thắt đó: hub nhận request, các node chạy trình duyệt, còn CAPTCHA thì mọi node đẩy về một chỗ — CaptchaAI, với một API key dùng chung. Node gửi task tới in.php, polling res.php cho đến khi có token, rồi đặt token vào form.

Bài này gồm file Docker Compose dựng hub và ba node Chrome, client Python dùng chung, bản Java tương đương, và cách tính số thread cho khớp số slot — chỗ đội nào cũng tính hụt trong lần chạy đầu.


Khi nào Grid đáng công dựng

Grid chỉ đáng dựng khi bạn cần quy mô:

  1. Trên 20 phiên trình duyệt cùng lúc — ví dụ một agency ở TP.HCM chạy hồi quy đêm cho form đăng ký của nhiều khách hàng trên staging, form nào cũng có reCAPTCHA v2.
  2. Nhiều trình duyệt trong một lần chạy: Chrome cho luồng chính, Firefox và Edge cho test tương thích.
  3. Job theo dõi giá công khai trên Shopee, Lazada hay Tiki cho danh mục của chính bạn, chạy mỗi đêm.

Dưới năm phiên song song thì một máy với ThreadPoolExecutor vẫn gọn hơn.


Kiến trúc: một hub, nhiều node, một API key

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

Điểm cần nhớ: CAPTCHA không được giải trên node. Node chỉ mở trang, lấy sitekey, gọi API và nhận token. Thêm bao nhiêu node cũng không phải đổi cấu hình CaptchaAI — chỉ số request đồng thời tăng lên.


Dựng Grid 4 bằng Docker Compose

File compose cho hub và ba node Chrome

Mỗi node đặt SE_NODE_MAX_SESSIONS=5, tức 15 slot trình duyệt — con số bạn sẽ đối chiếu với số thread ở phần sau.

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

Khởi động toàn bộ Grid:

docker-compose up -d

Mở http://localhost:4444 để kiểm tra hub đã nhận đủ ba node chưa. Thiếu node thường là do node khởi động trước hub; restart riêng node đó là xong.


Client CaptchaAI dùng chung cho mọi node

Luồng xử lý giống hệt khi chạy trên một máy, chỉ khác webdriver.Remote trỏ vào hub. Với mỗi task: mở trang, đọc sitekey, gửi task tới in.php, polling res.php, đặt token vào g-recaptcha-response rồi submit.

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

process_task luôn gọi driver.quit() trong finally. Bỏ chi tiết này là nguyên nhân số một khiến Grid cạn slot: phiên lỗi không bao giờ được trả về.


Chạy nhiều task song song trên Grid

Số worker ở client nên bằng hoặc thấp hơn số slot của Grid. Đặt cao hơn không nhanh hơn — request thừa chỉ nằm chờ ở hub và dễ chạm timeout khi tạo phiên.

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

Đọc số slot trống trước khi tăng worker

Thay vì đoán, hãy hỏi hub: endpoint /status trả về danh sách node kèm số slot đang bận.

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

Với job theo lịch, gọi hàm này ở đầu mỗi lần chạy — số node có thể đã bị thu nhỏ.


Tự động co giãn node bằng Kubernetes

Khi hồi quy chỉ chạy từ 1h đến 4h sáng, HorizontalPodAutoscaler giữ 2 node lúc rảnh và đẩy lên 20 node lúc cao điểm.

# 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

Lưu ý resources: mỗi pod Chrome cần khoảng 2Gi bộ nhớ cho ba phiên. Đặt thấp hơn thì pod bị OOM kill, và phía client chỉ thấy WebDriverException chứ không thấy lỗi bộ nhớ.


Bản Java cho team dùng JVM

Nhiều đội QA ở Việt Nam đã có sẵn bộ test JUnit hoặc TestNG. Cấu trúc không đổi: RemoteWebDriver trỏ vào hub, ExecutorService giới hạn số luồng, gọi API bằng HttpClient.

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

Cân số slot Grid với số thread CaptchaAI

Grid quyết định bạn mở được bao nhiêu trình duyệt; gói CaptchaAI quyết định bao nhiêu CAPTCHA được giải cùng lúc. Một thread là một luồng giải đồng thời: phiên đang chờ token chiếm một thread, giải xong là nhận task tiếp.

  • BASIC ($15/tháng, 5 thread) — một node Chrome hoặc môi trường phát triển.
  • STANDARD ($30/tháng, 15 thread) — khớp đúng cấu hình ba node × 5 phiên ở trên.
  • ADVANCE ($90/tháng, 50 thread) — Grid khoảng mười node.
  • PREMIUM ($170/tháng, 100 thread) — hạ tầng chạy liên tục nhiều ca trong ngày.

Giá tính theo thread chứ không theo lượt giải, và số lượt giải trên mỗi thread trong tháng là không giới hạn. Nên chỉ có một câu cần trả lời: cao điểm có bao nhiêu phiên cùng chờ token? Hiếm khi cả 15 phiên chạm CAPTCHA cùng lúc, vì mỗi phiên còn mất thời gian tải trang. Giá niêm yết bằng USD.


Sự cố thường gặp trên Grid

Triệu chứng Nguyên nhân Cách xử lý
SessionNotCreated Không còn slot trống Thêm node hoặc tăng SE_NODE_MAX_SESSIONS
Timeout khi tạo phiên Node quá tải Giảm số phiên trên mỗi node
WebDriverException giữa chừng Node ngắt kết nối hoặc bị OOM kill Retry bước tạo phiên, nâng giới hạn bộ nhớ
Slot cạn dần Phiên lỗi không được đóng Luôn gọi driver.quit() trong finally
CAPCHA_NOT_READY kéo dài Đã dùng hết thread Giãn thời gian thử lại (backoff), kiểm tra số thread
Phiên cũ treo trên node Grid dọn dẹp chậm Đặt SE_SESSION_TIMEOUT

Loại CAPTCHA nào chạy được trong mô hình này

Node chỉ gửi sitekey cùng pageurl rồi nhận token, nên mô hình này dùng được với mọi loại CaptchaAI hỗ trợ: reCAPTCHA v2 và v3 (kể cả reCAPTCHA Enterprise), Cloudflare Turnstile, Cloudflare Challenge, GeeTest v3, CAPTCHA ảnh/OCR, grid-image, BLS CAPTCHA. Đang ở giai đoạn beta: CaptchaFox (beta), Friendly Captcha (beta), Lemin (beta).

CaptchaAI không hỗ trợ hCaptcha và FunCaptcha (Arkose Labs); GeeTest v4 mới ở trạng thái sắp ra mắt. Nếu hệ thống bạn kiểm thử dùng những loại đó, hãy tách chúng khỏi hàng đợi Grid từ khâu lập kế hoạch.


Câu hỏi thường gặp

Grid có 20 slot thì cần bao nhiêu thread?

Bắt đầu ở mức thread ngang số slot. Nếu chỉ khoảng một nửa số phiên chạm CAPTCHA cùng lúc thì 15 thread vẫn đủ — đo log trước khi nâng gói.

Token giải xong có dùng lại cho phiên khác được không?

Không. Token gắn với sitekey, với phiên đang mở trang và hết hạn sau ít phút — mỗi phiên phải xin token riêng.

Nên tạo phiên mới cho mỗi task hay dùng lại phiên?

Tạo mới cho mỗi task để cô lập cookie và state. Chỉ dùng lại khi các task cùng domain và cần giữ trạng thái đăng nhập.

CaptchaAI có giải hCaptcha trên Grid không?

Không — hCaptcha và FunCaptcha nằm ngoài danh sách hỗ trợ. Với các trang dùng chúng, hãy đề nghị đội phát triển tắt CAPTCHA trên staging.


Đọc thêm


Lấy API key CaptchaAI và cho cả ba node dùng chung một key ngay từ lần chạy đầu.

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