Tutorials

MongoDB for CAPTCHA 解决历史记录和分析

验证码识别脚本跑了几千次后,你多半想知道:今天成功率多少?哪类验证码最容易出错?把每次调用 CaptchaAI API 的结果写进 MongoDB,几行聚合查询就能给出答案,不用翻日志。

为什么用 MongoDB 记录验证码识别数据

不同验证码类型需要的字段并不一样:

  • reCAPTCHA 需要 googlekey
  • Turnstile 需要 sitekey
  • 图片验证码需要 body

关系型数据库得为每种类型单独建表,MongoDB 直接按实际字段存,加新类型无需迁移。做电商比价、对接多个站点时,可把项目名写进 metadata.project(如下面 price-monitor),一次聚合查询就能看出哪个站点最难处理。

验证码识别记录的文档结构设计

下面是一条典型的识别记录,字段含义一目了然:

{
  "_id": "ObjectId",
  "captcha_id": "12345678",
  "type": "recaptcha_v2",
  "method": "userrecaptcha",
  "sitekey": "6Le-wvkSAAAAAPBMRTvw0Q4Muexq9bi0DJwx_mJ-",
  "pageurl": "https://example.com/form",
  "status": "solved",
  "solution": "03AGdBq26...",
  "error": null,
  "submitted_at": "2026-04-04T10:15:30.000Z",
  "solved_at": "2026-04-04T10:15:45.000Z",
  "elapsed_ms": 15000,
  "polls": 3,
  "proxy_used": true,
  "cost": 0.00299,
  "metadata": {
    "project": "price-monitor",
    "worker_id": "worker-3",
    "target_domain": "example.com"
  }
}

Python 实现

环境准备与连接

开始前先做好两件事:

  1. 国内网络装包慢时,给 pip 加清华 TUNA 镜像:pip install -i https://pypi.tuna.tsinghua.edu.cn/simple pymongo requests
  2. MONGO_URICAPTCHAAI_API_KEY 写进环境变量,不要硬编码进脚本。
import os
import time
from datetime import datetime, timezone
from pymongo import MongoClient, ASCENDING, DESCENDING
import requests

MONGO_URI = os.environ.get("MONGO_URI", "mongodb://localhost:27017")
API_KEY = os.environ["CAPTCHAAI_API_KEY"]

client = MongoClient(MONGO_URI)
db = client["captcha_tracking"]
solves = db["solves"]

创建索引

聚合查询能跑多快全看索引,上线前先建好这几个:

def setup_indexes():
    solves.create_index([("submitted_at", DESCENDING)])
    solves.create_index([("type", ASCENDING), ("status", ASCENDING)])
    solves.create_index([("metadata.project", ASCENDING)])
    solves.create_index([("metadata.target_domain", ASCENDING)])
    solves.create_index(
        [("submitted_at", ASCENDING)],
        expireAfterSeconds=90 * 24 * 3600,  # Auto-delete after 90 days
        name="ttl_cleanup"
    )

setup_indexes()

识别并写入记录

先插入“已提交”记录,提交给 CaptchaAI API,每 5 秒轮询一次并更新回同一条文档:

提示:先写入 status: submitted 的占位文档,再逐步更新状态字段。脚本中途崩溃时,你能从数据库里直接看出任务卡在哪一步,不用翻日志。

def solve_and_store(sitekey, pageurl, captcha_type="recaptcha_v2", metadata=None):
    record = {
        "type": captcha_type,
        "method": "userrecaptcha",
        "sitekey": sitekey,
        "pageurl": pageurl,
        "status": "submitted",
        "submitted_at": datetime.now(timezone.utc),
        "metadata": metadata or {}
    }

    result = solves.insert_one(record)
    doc_id = result.inserted_id

    # Submit to CaptchaAI
    resp = requests.post("https://ocr.captchaai.com/in.php", data={
        "key": API_KEY,
        "method": "userrecaptcha",
        "googlekey": sitekey,
        "pageurl": pageurl,
        "json": 1
    })
    data = resp.json()

    if data.get("status") != 1:
        solves.update_one(
            {"_id": doc_id},
            {"$set": {"status": "error", "error": data.get("request")}}
        )
        return None

    captcha_id = data["request"]
    solves.update_one(
        {"_id": doc_id},
        {"$set": {"captcha_id": captcha_id, "status": "polling"}}
    )

    # Poll for result
    polls = 0
    for _ in range(60):
        time.sleep(5)
        polls += 1
        poll_resp = requests.get("https://ocr.captchaai.com/res.php", params={
            "key": API_KEY, "action": "get",
            "id": captcha_id, "json": 1
        }).json()

        if poll_resp.get("status") == 1:
            solved_at = datetime.now(timezone.utc)
            elapsed_ms = int(
                (solved_at - record["submitted_at"]).total_seconds() * 1000
            )
            solves.update_one({"_id": doc_id}, {"$set": {
                "status": "solved",
                "solution": poll_resp["request"],
                "solved_at": solved_at,
                "elapsed_ms": elapsed_ms,
                "polls": polls
            }})
            return poll_resp["request"]

        if poll_resp.get("request") != "CAPCHA_NOT_READY":
            solves.update_one({"_id": doc_id}, {"$set": {
                "status": "error",
                "error": poll_resp.get("request"),
                "polls": polls
            }})
            return None

    solves.update_one({"_id": doc_id}, {"$set": {
        "status": "timeout", "polls": polls
    }})
    return None

分析查询函数

4 个函数覆盖日常排查:

函数 用途
get_success_rate() 按时段统计成功率
get_avg_solve_time_by_type() 按验证码类型统计平均耗时
get_hourly_solve_volume() 按小时统计识别量,用于画图
get_error_breakdown() 按错误码统计失败原因
def get_success_rate(hours=24):
    """Success rate for the last N hours."""
    from datetime import timedelta
    cutoff = datetime.now(timezone.utc) - timedelta(hours=hours)

    pipeline = [
        {"$match": {"submitted_at": {"$gte": cutoff}}},
        {"$group": {
            "_id": "$status",
            "count": {"$sum": 1}
        }}
    ]
    results = {r["_id"]: r["count"] for r in solves.aggregate(pipeline)}
    total = sum(results.values())
    solved = results.get("solved", 0)
    return (solved / total * 100) if total else 0


def get_avg_solve_time_by_type():
    """Average solve time grouped by CAPTCHA type."""
    pipeline = [
        {"$match": {"status": "solved"}},
        {"$group": {
            "_id": "$type",
            "avg_time_ms": {"$avg": "$elapsed_ms"},
            "min_time_ms": {"$min": "$elapsed_ms"},
            "max_time_ms": {"$max": "$elapsed_ms"},
            "count": {"$sum": 1}
        }},
        {"$sort": {"count": -1}}
    ]
    return list(solves.aggregate(pipeline))


def get_hourly_solve_volume(days=7):
    """Hourly solve volume for charting."""
    from datetime import timedelta
    cutoff = datetime.now(timezone.utc) - timedelta(days=days)

    pipeline = [
        {"$match": {"submitted_at": {"$gte": cutoff}}},
        {"$group": {
            "_id": {
                "date": {"$dateToString": {"format": "%Y-%m-%d", "date": "$submitted_at"}},
                "hour": {"$hour": "$submitted_at"}
            },
            "total": {"$sum": 1},
            "solved": {"$sum": {"$cond": [{"$eq": ["$status", "solved"]}, 1, 0]}}
        }},
        {"$sort": {"_id.date": 1, "_id.hour": 1}}
    ]
    return list(solves.aggregate(pipeline))


def get_error_breakdown(hours=24):
    """Error frequency by error code."""
    from datetime import timedelta
    cutoff = datetime.now(timezone.utc) - timedelta(hours=hours)

    pipeline = [
        {"$match": {"submitted_at": {"$gte": cutoff}, "status": "error"}},
        {"$group": {"_id": "$error", "count": {"$sum": 1}}},
        {"$sort": {"count": -1}}
    ]
    return list(solves.aggregate(pipeline))

Node.js 实现

采集脚本用 Node.js 写的话,同样逻辑可以原样搬过去,只是三处不同:

  • axios 替代 requests
  • 轮询循环用 async/await 包一层 setTimeout
  • 字段名按 JS 惯例写成驼峰式(如 insertedId
const { MongoClient } = require("mongodb");
const axios = require("axios");

const MONGO_URI = process.env.MONGO_URI || "mongodb://localhost:27017";
const API_KEY = process.env.CAPTCHAAI_API_KEY;

let db, solves;

async function connect() {
  const client = await MongoClient.connect(MONGO_URI);
  db = client.db("captcha_tracking");
  solves = db.collection("solves");

  await solves.createIndex({ submitted_at: -1 });
  await solves.createIndex({ type: 1, status: 1 });
  await solves.createIndex({ "metadata.project": 1 });
  await solves.createIndex(
    { submitted_at: 1 },
    { expireAfterSeconds: 90 * 24 * 3600 }
  );
}

async function solveAndStore(sitekey, pageurl, type = "recaptcha_v2", metadata = {}) {
  const submittedAt = new Date();
  const { insertedId } = await solves.insertOne({
    type, method: "userrecaptcha", sitekey, pageurl,
    status: "submitted", submitted_at: submittedAt, metadata,
  });

  const submit = await axios.post("https://ocr.captchaai.com/in.php", null, {
    params: { key: API_KEY, method: "userrecaptcha", googlekey: sitekey, pageurl, json: 1 },
  });

  if (submit.data.status !== 1) {
    await solves.updateOne({ _id: insertedId }, { $set: { status: "error", error: submit.data.request } });
    return null;
  }

  const captchaId = submit.data.request;
  await solves.updateOne({ _id: insertedId }, { $set: { captcha_id: captchaId, status: "polling" } });

  let polls = 0;
  for (let i = 0; i < 60; i++) {
    await new Promise((r) => setTimeout(r, 5000));
    polls++;
    const poll = await axios.get("https://ocr.captchaai.com/res.php", {
      params: { key: API_KEY, action: "get", id: captchaId, json: 1 },
    });

    if (poll.data.status === 1) {
      const solvedAt = new Date();
      await solves.updateOne({ _id: insertedId }, { $set: {
        status: "solved", solution: poll.data.request,
        solved_at: solvedAt, elapsed_ms: solvedAt - submittedAt, polls,
      }});
      return poll.data.request;
    }
    if (poll.data.request !== "CAPCHA_NOT_READY") {
      await solves.updateOne({ _id: insertedId }, { $set: { status: "error", error: poll.data.request, polls } });
      return null;
    }
  }

  await solves.updateOne({ _id: insertedId }, { $set: { status: "timeout", polls } });
  return null;
}

async function getSuccessRate(hours = 24) {
  const cutoff = new Date(Date.now() - hours * 3600 * 1000);
  const pipeline = [
    { $match: { submitted_at: { $gte: cutoff } } },
    { $group: { _id: "$status", count: { $sum: 1 } } },
  ];
  const results = await solves.aggregate(pipeline).toArray();
  const total = results.reduce((s, r) => s + r.count, 0);
  const solved = results.find((r) => r._id === "solved")?.count || 0;
  return total ? ((solved / total) * 100).toFixed(1) : 0;
}

验证码识别记录的数据保留策略

记录会越攒越多,提前定好保留期限更省心。常见三种策略:

策略 TTL 索引 适用场景
保留 30 天 expireAfterSeconds: 2592000 开发/测试环境
保留 90 天 expireAfterSeconds: 7776000 生产环境分析
永久保留(另做归档) 不设 TTL;改用封顶集合或冷存储 合规审计场景

合规提示:记录含来源 URL、站点等信息时,《网络安全法》《数据安全法》和 PIPL 对留存期限有要求——只留业务需要的字段。

排查常见问题

问题 原因 处理方式
聚合查询很慢 submitted_attype 上没建索引 执行一次 setup_indexes() —— 参考上面的索引部分
文档体积越来越大 每条记录都存了完整的识别结果 只存哈希值,或者用完就截断字段
TTL 索引不删旧数据 TTL 后台线程每 60 秒才扫一次,积压数据多时需要等待 等后台任务完成清理;用 db.solves.getIndexes() 确认索引存在
连接池被打满 并发识别任务太多 在连接字符串里设置 maxPoolSize

常见问题

MongoDB 相比 SQLite、Redis,优势在哪?

单机脚本用 SQLite 缓存 更轻量;短期 token 过期,Redis TTL 更快。长期存历史、跑聚合统计,MongoDB 更合适。

按项目或站点统计成功率怎么写?

metadata 记了 projecttarget_domain 的话,在 $match 阶段按字段过滤,再走一遍 get_success_rate() 逻辑即可。

要不要存完整的识别 token?

排查问题先存 24–48 小时,交给 TTL 自动清理;长期分析只留类型、时间、状态、错误码——token 过期就没用了。

本地部署还是 MongoDB Atlas?

代码一样。Atlas 支持 TTL 索引和聚合管道,把连接字符串填进 MONGO_URI 即可。团队协作选 Atlas 更省事。

下一步

脚本跑起来只是第一步,你还得知道它跑得好不好——获取 CaptchaAI API Key,把记录分析流程接到你的项目里。

相关指南:

  • 用 SQLite 做本地验证码识别缓存
  • 用 Redis 管理 token 的 TTL
  • 验证码识别性能的时间序列趋势
该文章已禁用评论。