验证码识别脚本跑了几千次后,你多半想知道:今天成功率多少?哪类验证码最容易出错?把每次调用 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 实现
环境准备与连接
开始前先做好两件事:
- 国内网络装包慢时,给 pip 加清华 TUNA 镜像:
pip install -i https://pypi.tuna.tsinghua.edu.cn/simple pymongo requests - 把
MONGO_URI和CAPTCHAAI_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_at、type 上没建索引 |
执行一次 setup_indexes() —— 参考上面的索引部分 |
| 文档体积越来越大 | 每条记录都存了完整的识别结果 | 只存哈希值,或者用完就截断字段 |
| TTL 索引不删旧数据 | TTL 后台线程每 60 秒才扫一次,积压数据多时需要等待 | 等后台任务完成清理;用 db.solves.getIndexes() 确认索引存在 |
| 连接池被打满 | 并发识别任务太多 | 在连接字符串里设置 maxPoolSize |
常见问题
MongoDB 相比 SQLite、Redis,优势在哪?
单机脚本用 SQLite 缓存 更轻量;短期 token 过期,Redis TTL 更快。长期存历史、跑聚合统计,MongoDB 更合适。
按项目或站点统计成功率怎么写?
metadata 记了 project、target_domain 的话,在 $match 阶段按字段过滤,再走一遍 get_success_rate() 逻辑即可。
要不要存完整的识别 token?
排查问题先存 24–48 小时,交给 TTL 自动清理;长期分析只留类型、时间、状态、错误码——token 过期就没用了。
本地部署还是 MongoDB Atlas?
代码一样。Atlas 支持 TTL 索引和聚合管道,把连接字符串填进 MONGO_URI 即可。团队协作选 Atlas 更省事。
下一步
脚本跑起来只是第一步,你还得知道它跑得好不好——获取 CaptchaAI API Key,把记录分析流程接到你的项目里。
相关指南:
- 用 SQLite 做本地验证码识别缓存
- 用 Redis 管理 token 的 TTL
- 验证码识别性能的时间序列趋势