Tutorials

使用 Node.js 和 CaptchaAI 构建 CAPTCHA 解决事件总线

多数 Node.js 验证码自动化脚本会把提交、轮询、重试、日志全部堆进同一个 solve() 函数——功能越加越多,这个函数就越难改。

更简单的做法是用 Node.js 内置的 EventEmitter:把 submitted、pending、solved、failed、timeout 这几种状态各自广播出去,谁关心谁监听,互不干扰。

如果你的自动化系统需要同时对接 reCAPTCHA v2、Cloudflare Turnstile、GeeTest v3 这类不同验证码,把重试、指标采集、日志写入全塞进同一段回调代码,很快就会变成一团面条。

本文用一个可以直接复用的 CaptchaBus 类,演示怎样把 CaptchaAI 的求解生命周期变成事件流,并给出 JavaScript 和 Python 两种实现——如果你在国内网络环境下拉取依赖较慢,装包时可以加上淘宝镜像:npm install axios --registry=https://registry.npmmirror.com。

事件总线架构:谁广播,谁监听

整体结构如下:

[CaptchaBus]
   ├── emit("submitted", { taskId, type, pageurl })
   ├── emit("pending", { taskId, elapsed })
   ├── emit("solved", { taskId, solution, duration })
   ├── emit("failed", { taskId, error, duration })
   └── emit("timeout", { taskId, elapsed })
        ↓          ↓           ↓
   [Logger]    [Metrics]   [Retry Handler]

监听器彼此独立注册。

新增一个功能——比如接入指标收集——不需要改动求解逻辑里的任何一行代码。

这正是事件驱动架构相比过程式回调最大的优势:能力增加不会污染已有代码。

CaptchaBus 类:JavaScript 实现

下面这个 CaptchaBus 类继承 Node.js 内置的 EventEmitter,负责提交任务、后台轮询,并在五种生命周期节点上广播事件。

不管是 userrecaptcha、turnstile 还是 geetest,逻辑都不变,只是 method 参数不同:

const EventEmitter = require("events");
const axios = require("axios");

class CaptchaBus extends EventEmitter {
  constructor(apiKey, options = {}) {
    super();
    this.apiKey = apiKey;
    this.pollInterval = options.pollInterval || 5000;
    this.maxWait = options.maxWait || 300000; // 5 minutes
    this.pending = new Map();
  }

  async submit(params) {
    const { method, sitekey, pageurl, ...extra } = params;
    const taskId = `task_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;

    const submitParams = {
      key: this.apiKey,
      method: method || "userrecaptcha",
      googlekey: sitekey,
      pageurl: pageurl,
      json: 1,
      ...extra,
    };

    try {
      const resp = await axios.post(
        "https://ocr.captchaai.com/in.php",
        null,
        { params: submitParams }
      );

      if (resp.data.status !== 1) {
        this.emit("failed", {
          taskId,
          error: resp.data.request,
          duration: 0,
        });
        return null;
      }

      const captchaId = resp.data.request;
      const startTime = Date.now();

      this.emit("submitted", {
        taskId,
        captchaId,
        method: method || "userrecaptcha",
        pageurl,
      });

      // Start polling
      this._poll(taskId, captchaId, startTime);
      return taskId;
    } catch (err) {
      this.emit("failed", { taskId, error: err.message, duration: 0 });
      return null;
    }
  }

  async _poll(taskId, captchaId, startTime) {
    const check = async () => {
      const elapsed = Date.now() - startTime;

      if (elapsed > this.maxWait) {
        this.emit("timeout", { taskId, elapsed });
        return;
      }

      this.emit("pending", { taskId, elapsed });

      try {
        const resp = await axios.get("https://ocr.captchaai.com/res.php", {
          params: {
            key: this.apiKey,
            action: "get",
            id: captchaId,
            json: 1,
          },
        });

        if (resp.data.status === 1) {
          this.emit("solved", {
            taskId,
            captchaId,
            solution: resp.data.request,
            duration: Date.now() - startTime,
          });
        } else if (resp.data.request === "CAPCHA_NOT_READY") {
          setTimeout(check, this.pollInterval);
        } else {
          this.emit("failed", {
            taskId,
            error: resp.data.request,
            duration: Date.now() - startTime,
          });
        }
      } catch (err) {
        this.emit("failed", {
          taskId,
          error: err.message,
          duration: Date.now() - startTime,
        });
      }
    };

    setTimeout(check, this.pollInterval);
  }
}

module.exports = CaptchaBus;

日志、指标一次性接入监听器

CaptchaBus 实例化之后,日志、指标统计、告警可以各自注册一个监听器,互不干扰。

也不用改动 submit()/_poll() 里的任何代码:

const CaptchaBus = require("./captcha-bus");

const bus = new CaptchaBus(process.env.CAPTCHAAI_API_KEY, {
  pollInterval: 5000,
  maxWait: 120000,
});

// Logging listener
bus.on("submitted", (e) => {
  console.log(`[SUBMIT] ${e.taskId} → ${e.method} on ${e.pageurl}`);
});

bus.on("pending", (e) => {
  console.log(`[PENDING] ${e.taskId} — ${(e.elapsed / 1000).toFixed(1)}s`);
});

bus.on("solved", (e) => {
  console.log(
    `[SOLVED] ${e.taskId} in ${(e.duration / 1000).toFixed(1)}s — ${e.solution.substring(0, 30)}...`
  );
});

bus.on("failed", (e) => {
  console.error(`[FAILED] ${e.taskId} — ${e.error}`);
});

bus.on("timeout", (e) => {
  console.error(
    `[TIMEOUT] ${e.taskId} after ${(e.elapsed / 1000).toFixed(1)}s`
  );
});

// Metrics listener
const metrics = { submitted: 0, solved: 0, failed: 0, totalDuration: 0 };

bus.on("submitted", () => metrics.submitted++);
bus.on("solved", (e) => {
  metrics.solved++;
  metrics.totalDuration += e.duration;
});
bus.on("failed", () => metrics.failed++);

// Submit a CAPTCHA
bus.submit({
  sitekey: "6Le-wvkSAAAAAPBMRTvw0Q4Muexq9bi0DJwx_mJ-",
  pageurl: "https://example.com",
});

Python 版本:同样的事件语义

如果你的技术栈是 Python,不需要额外框架,一个监听器字典就能实现同样的事件驱动模式。

轮询放进后台线程里跑:

import os
import time
import threading
from collections import defaultdict
import requests


class CaptchaBus:
    def __init__(self, api_key, poll_interval=5, max_wait=300):
        self.api_key = api_key
        self.poll_interval = poll_interval
        self.max_wait = max_wait
        self._listeners = defaultdict(list)

    def on(self, event, callback):
        """Register a listener for an event."""
        self._listeners[event].append(callback)
        return self

    def emit(self, event, data):
        """Emit an event to all registered listeners."""
        for callback in self._listeners.get(event, []):
            try:
                callback(data)
            except Exception as e:
                print(f"Listener error on {event}: {e}")

    def submit(self, sitekey, pageurl, method="userrecaptcha", **extra):
        """Submit a CAPTCHA and begin tracking."""
        task_id = f"task_{int(time.time())}_{id(sitekey) % 10000}"

        resp = requests.post("https://ocr.captchaai.com/in.php", data={
            "key": self.api_key,
            "method": method,
            "googlekey": sitekey,
            "pageurl": pageurl,
            "json": 1,
            **extra
        })
        data = resp.json()

        if data.get("status") != 1:
            self.emit("failed", {
                "task_id": task_id,
                "error": data.get("request"),
                "duration": 0
            })
            return None

        captcha_id = data["request"]
        start_time = time.time()

        self.emit("submitted", {
            "task_id": task_id,
            "captcha_id": captcha_id,
            "method": method,
            "pageurl": pageurl
        })

        # Poll in a background thread
        thread = threading.Thread(
            target=self._poll,
            args=(task_id, captcha_id, start_time),
            daemon=True
        )
        thread.start()
        return task_id

    def _poll(self, task_id, captcha_id, start_time):
        while True:
            elapsed = time.time() - start_time

            if elapsed > self.max_wait:
                self.emit("timeout", {"task_id": task_id, "elapsed": elapsed})
                return

            time.sleep(self.poll_interval)
            self.emit("pending", {"task_id": task_id, "elapsed": elapsed})

            resp = requests.get("https://ocr.captchaai.com/res.php", params={
                "key": self.api_key,
                "action": "get",
                "id": captcha_id,
                "json": 1
            })
            data = resp.json()

            if data.get("status") == 1:
                self.emit("solved", {
                    "task_id": task_id,
                    "solution": data["request"],
                    "duration": time.time() - start_time
                })
                return
            elif data.get("request") != "CAPCHA_NOT_READY":
                self.emit("failed", {
                    "task_id": task_id,
                    "error": data.get("request"),
                    "duration": time.time() - start_time
                })
                return


# Usage
bus = CaptchaBus(os.environ["CAPTCHAAI_API_KEY"])

bus.on("submitted", lambda e: print(f"[SUBMIT] {e['task_id']}"))
bus.on("solved", lambda e: print(f"[SOLVED] {e['task_id']} in {e['duration']:.1f}s"))
bus.on("failed", lambda e: print(f"[FAILED] {e['task_id']} — {e['error']}"))
bus.on("timeout", lambda e: print(f"[TIMEOUT] {e['task_id']}"))

bus.submit("6Le-wvkSAAAAAPBMRTvw0Q4Muexq9bi0DJwx_mJ-", "https://example.com")

进阶用法:用监听器实现自动重试

失败重试本身也可以只是一个监听器。

不需要侵入 submit() 的实现:

// Automatic retry on failure
bus.on("failed", async (e) => {
  if (e.retryCount >= 3) {
    console.error(`[GIVE UP] ${e.taskId} after 3 retries`);
    return;
  }

  console.log(`[RETRY] ${e.taskId} — attempt ${(e.retryCount || 0) + 1}`);
  await bus.submit({
    ...e.originalParams,
    _retryCount: (e.retryCount || 0) + 1,
  });
});

进阶用法:封装成 Promise 风格 API

有些调用方更习惯 async/await,而不是监听事件。

这种情况下,可以在事件总线外面再包一层 Promise:

function solveCaptcha(bus, params) {
  return new Promise((resolve, reject) => {
    const taskId = bus.submit(params);

    function onSolved(e) {
      if (e.taskId === taskId) {
        cleanup();
        resolve(e.solution);
      }
    }

    function onFailed(e) {
      if (e.taskId === taskId) {
        cleanup();
        reject(new Error(e.error));
      }
    }

    function cleanup() {
      bus.removeListener("solved", onSolved);
      bus.removeListener("failed", onFailed);
      bus.removeListener("timeout", onFailed);
    }

    bus.on("solved", onSolved);
    bus.on("failed", onFailed);
    bus.on("timeout", onFailed);
  });
}

// Usage
const solution = await solveCaptcha(bus, {
  sitekey: "6Le-wvkSAAAAAPBMRTvw0Q4Muexq9bi0DJwx_mJ-",
  pageurl: "https://example.com",
});

常见故障排查

问题 原因 处理方式
监听器不触发 事件名拼写不一致(比如注册的是 solve 而广播的是 solved) 逐一核对 emit() 和 on() 里用的事件名,确保完全一致
出现内存泄漏警告 同一个事件上注册了太多监听器 调用 setMaxListeners() 调高上限,或在用完后及时移除监听器
控制台被 pending 事件刷屏 轮询间隔设得太短 把 pollInterval 调到 5000 毫秒以上
重试之后事件对不上 重试会生成一个全新的 taskId 把原始参数一起传下去,重新绑定状态

常见问题

事件总线能同时处理 reCAPTCHA v2、Turnstile 和 GeeTest v3 吗?

可以。CaptchaBus 本身不关心验证码类型,类型由 submit() 传入的 method 参数决定(userrecaptcha、turnstile、geetest)。

同一个实例上监听 solved/failed 就够了,不需要为每种类型分别写一套求解逻辑。

需要引入 Kafka、RabbitMQ 这样的消息队列吗?

单进程应用没必要——进程内的 EventEmitter 更简单也更快。

只有当多个进程或多个服务都要对同一批验证码事件做出反应时,才值得上外部消息队列。

监听器越加越多,会不会导致内存泄漏?

Node.js 默认给单个事件最多挂 10 个监听器,超过会打印警告,但不代表一定内存泄漏。

长期运行的服务里,记得在监听器用完后调用 removeListener(),或者用 once() 注册一次性监听器。

想保留完整的求解记录做审计,应该怎么做?

给 submitted/solved/failed 都挂一个监听器,把事件写进 JSONL 文件或数据库即可。

不需要改动 submit()/_poll() 里的任何一行求解逻辑。

相关文章

下一步

把回调堆叠改成事件驱动。

获取你的 CaptchaAI API 密钥,接入你自己的验证码事件总线。

相关指南:

该文章已禁用评论。