如何在 APScheduler 中识别验证码,不再跳过运行

APScheduler 验证码作业就是一个调用 CapSkip 的普通函数,用 interval 触发器来调度。识别本身只要一行代码。问题出在它周围的默认设置上。APScheduler 同一时间只运行一个作业实例。CapSkip Python SDK 的同步客户端不给它的 HTTP 调用设置任何超时。两者加在一起,意味着只要有一次识别永不返回,之后的每一次运行都会被挡住,而唯一的迹象是每十分钟出现一条警告。开始时间晚了一秒以上的运行会被丢弃。另外,在 Windows 上使用 Python 3.13 或更早版本时,按 Ctrl+C 不会有任何反应,直到下一次运行到期为止。本指南会在一个每十分钟登录一次受 reCAPTCHA 保护站点的作业里,把这三个问题全部解决。
你需要什么
- 在 Windows 机器上运行的 CapSkip,并已开启 reCAPTCHA 识别功能。
- Python 3.10 或更高版本。下面的代码已在 Python 3.13.7 和 3.14.0 上运行过。
- APScheduler 3.11(当前的稳定版本线),以及 capskip 和 requests 包。把 APScheduler 的版本限定在 4 以下:4.0 的预发布版 API 不同,下面的导入在它们上面会失败。
- 识别工具的地址。在 Local 模式下,CapSkip 只在 127.0.0.1 上响应,仅供本机使用。在 Server 模式下,它监听你的网络地址或公网 IP,这样另一台机器上的调度器就能通过 API 调用它。两种模式的设置位置都是 连接设置.
第 1 步:一个识别验证码的作业
APScheduler 作业可以是任何函数。这个作业每十分钟向 CapSkip 请求一次 reCAPTCHA token。要写 127.0.0.1 而不是 localhost:在 Windows 上,localhost 会先尝试 IPv6 地址,在我们的测试中,这让报告识别工具未运行所需的时间翻了一倍,从 2 秒变成 4 秒。
# pip install "apscheduler<4" capskip
from apscheduler.schedulers.blocking import BlockingScheduler
from capskip import CapSkip
solver = CapSkip(host="127.0.0.1", port=8080)
def solve_login_captcha():
result = solver.recaptcha(sitekey="YOUR_SITEKEY", url="https://example.com/login")
print("token:", result["code"][:20], "...")
scheduler = BlockingScheduler()
scheduler.add_job(solve_login_captcha, "interval", minutes=10)
scheduler.start()BlockingScheduler 在主线程中运行,并把每次运行交给一个有 10 个工作线程的线程池。第一次运行发生在启动后一个间隔,而不是启动时。在某次识别出问题之前,这都没什么,而本指南剩下的部分讲的正是这种情况。这些简短示例为了少写代码而使用 BlockingScheduler。在 Windows 上使用 Python 3.13 或更早版本时,请改用下面完整脚本中的循环。
为什么一次慢识别会跳过下一次运行
每个作业的 max_instances 默认都是 1。当一次运行到期而上一次还在运行时,APScheduler 不会启动第二个实例,而是在 apscheduler.scheduler 日志记录器下以 WARNING 级别记录一条警告:
Execution of job "solve_login_captcha (trigger: interval[0:10:00], next run at: 2026-10-07 13:10:00 +08)" skipped: maximum number of running instances reached (1)
对验证码作业来说,这个默认值是对的。你绝不会想为同一个登录同时进行两次识别。一次识别如果比间隔稍长一点,代价只是跳过一次运行,作业会在下一个计划时间继续。在一次间隔 5 秒、识别耗时 12.8 秒的测试中,每次识别都会跳过两次运行,而下一次运行是在随后的计划时间开始的,并不是识别一结束就立即开始。
麻烦出在一次识别永远不结束的时候。这时唯一允许的那个实例会一直被占着,之后的每一次运行都会以同样的警告被跳过。作业看起来已调度,日志看起来也还活着,可什么都没发生。
第 2 步:给 SDK 设置 HTTP 超时
CapSkip Python SDK 调用 requests.post 和 requests.get 时不带超时。如果 CapSkip 接受了连接却始终不应答,比如因为应用卡死了,这次调用就会永远等下去。SDK 的 recaptchaTimeout(默认 300 秒)帮不上忙,因为它只在两次轮询之间检查,而一次挂起的轮询永远不会结束。在我们的测试中,以这种方式卡住的作业,在识别工具恢复之后仍然卡着,日志里只有跳过警告。
两个显而易见的修复办法都不管用。socket.setdefaulttimeout 没有效果,因为 requests 总会把自己的超时值传下去,即使这个值是 None。而且 SDK 不接受你可以自行配置的会话对象。真正可行的办法,是给 SDK 的模块一个代替 requests 包的小替身,让它的 post 和 get 调用总是带上超时。
# pip install capskip requests
import functools
import types
import requests
import capskip.api
from capskip import CapSkip
# The SDK calls requests.post/get with no timeout. Hand its module a
# stand-in whose calls always pass one: 5 s to connect, 30 s to answer.
capskip.api.requests = types.SimpleNamespace(
RequestException=requests.RequestException,
post=functools.partial(requests.post, timeout=(5, 30)),
get=functools.partial(requests.get, timeout=(5, 30)),
) # do this before the first solve
solver = CapSkip(host="127.0.0.1", port=8080)这会替换 capskip.api 中的 requests 名称,在 capskip 1.3.0 中,正是这个模块负责把 SDK 的请求发给 CapSkip。升级 SDK 之后要再检查一遍。在第一次识别之前运行一次,之后每次调用都有 5 秒用于建立连接,30 秒用于等待应答。
这样,两种挂起的结局就不同了。如果 CapSkip 在提交任务时停止应答,作业会在 30 秒后失败,抛出提示 read timed out 的 NetworkException。如果它在 SDK 轮询结果时停止应答,SDK 会把超时当成尚未就绪,继续轮询,于是作业在 recaptchaTimeout 到期时以 TimeoutException 结束。使用默认值时,这在我们的测试中花了 333 秒。因此最坏情况大约是五分半钟,在十分钟的间隔之内,下一次运行会正常识别。
第 3 步:决定迟到的运行如何处理
第二个默认值是 misfire_grace_time,它是 1 秒。开始时间比计划时间晚了一秒以上的运行会被丢弃,APScheduler 会记录一条警告,说这次运行时间被错过了,并附上延迟时长。运行迟到的情况比你想象的更常见。线程池的线程全都忙于识别,或者笔记本电脑在某次运行时处于睡眠状态,就足以造成迟到。
# pip install "apscheduler<4" capskip
from datetime import datetime
from apscheduler.schedulers.blocking import BlockingScheduler
from capskip import CapSkip
solver = CapSkip(host="127.0.0.1", port=8080)
def solve_login_captcha():
result = solver.recaptcha(sitekey="YOUR_SITEKEY", url="https://example.com/login")
print("token:", result["code"][:20])
scheduler = BlockingScheduler()
scheduler.add_job(
solve_login_captcha, "interval", minutes=10,
next_run_time=datetime.now(), # first run now, not ten minutes from now
jitter=30, # add 0 to 30 s to each run
max_instances=1, # the default: a slow solve skips the next run
coalesce=True, # the default: after a stall, run once
misfire_grace_time=None, # default is 1 s; None runs late instead of dropping
)
scheduler.start()把 misfire_grace_time 设为 None,迟到的运行仍会执行,只是晚一些。让 coalesce 选项保持默认值 True,这样在长时间停顿之后,多次错过的运行会合并成一次,而不是一个接一个地运行。我们在 4 秒的间隔下让调度器暂停了 14 秒,以模拟一台进入睡眠模式的电脑。使用默认值时,补跑的那次运行被丢弃了。把 misfire_grace_time 设为 None 后,它运行了一次。如果再把 coalesce 选项设为 False,它会连续运行三次,为一次登录做三次验证码识别。
传入 next_run_time,就能在启动时立刻运行作业。jitter 选项会给每次运行加上一个最多为该秒数的随机延迟,从而把请求分散开。每个延迟都是加在上一次运行时间上的,而上一次运行时间本身已经包含了延迟,所以平均周期会变成间隔加上 jitter 的一半。
第 4 步:失败一发生就能看到
作业中抛出的异常不会让调度器停止。APScheduler 会以 ERROR 级别记录它,附上作业抛出了异常的消息和完整的 traceback,而作业照常按计划运行。监听器会以对象的形式收到同样的事件,这样你就可以统计它们、据此告警,或者把它们写到你想要的地方。
# pip install "apscheduler<4" capskip
import logging
from apscheduler.events import EVENT_JOB_ERROR, EVENT_JOB_MAX_INSTANCES, EVENT_JOB_MISSED
from apscheduler.schedulers.blocking import BlockingScheduler
from capskip import CapSkip
logging.basicConfig(level=logging.INFO)
solver = CapSkip(host="127.0.0.1", port=8080)
def solve_login_captcha():
solver.recaptcha(sitekey="YOUR_SITEKEY", url="https://example.com/login")
def on_event(event):
if event.code == EVENT_JOB_ERROR:
logging.error("job %s failed: %r", event.job_id, event.exception)
elif event.code == EVENT_JOB_MAX_INSTANCES:
logging.warning("job %s still solving, this run skipped", event.job_id)
else:
logging.warning("job %s started too late, this run dropped", event.job_id)
scheduler = BlockingScheduler()
scheduler.add_listener(on_event, EVENT_JOB_ERROR | EVENT_JOB_MAX_INSTANCES | EVENT_JOB_MISSED)
scheduler.add_job(solve_login_captcha, "interval", minutes=10, id="login")
scheduler.start()对验证码作业来说,三个事件就够了。EVENT_JOB_ERROR 带有异常本身,比如消息为 ERROR_CAPTCHA_UNSOLVABLE 的 ApiException。EVENT_JOB_MAX_INSTANCES 在每次运行被跳过时触发一次,这样一次卡住的识别就会表现为不断上升的计数。EVENT_JOB_MISSED 会在每次迟到的运行被丢弃时触发。
完整的 APScheduler 验证码作业
这个脚本用一个 requests 会话获取登录页面,读取 sitekey 和 CSRF 字段,用 CapSkip 识别 reCAPTCHA,再带着 token 提交表单。它在作业内部捕获 CapSkip 的错误,所以一次识别失败只是一条警告,下一次运行会再试。
# pip install "apscheduler<4" capskip requests
import functools
import logging
import re
import time
import types
from datetime import datetime
import requests
import capskip.api
from apscheduler.schedulers.background import BackgroundScheduler
from capskip import CapSkip, CapSkipError
LOGIN_URL = "https://example.com/login"
USERNAME, PASSWORD = "YOUR_USERNAME", "YOUR_PASSWORD"
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
log = logging.getLogger("login-job")
# The SDK's HTTP calls have no timeout; give them one so a stuck CapSkip
# cannot hang the job.
capskip.api.requests = types.SimpleNamespace(
RequestException=requests.RequestException,
post=functools.partial(requests.post, timeout=(5, 30)),
get=functools.partial(requests.get, timeout=(5, 30)),
)
solver = CapSkip(host="127.0.0.1", port=8080)
def field(html, pattern):
match = re.search(pattern, html)
if not match:
raise ValueError(f"not found on the login page: {pattern}")
return match.group(1)
def login():
with requests.Session() as session:
page = session.get(LOGIN_URL, timeout=30)
sitekey = field(page.text, r'data-sitekey="([^"]+)"')
# The form's hidden CSRF field (Django names it csrfmiddlewaretoken).
csrf = field(page.text, r'name="csrfmiddlewaretoken" value="([^"]+)"')
try:
token = solver.recaptcha(sitekey=sitekey, url=LOGIN_URL)["code"]
except CapSkipError as exc:
log.warning("CapSkip could not solve: %s: %s", type(exc).__name__, exc)
return
reply = session.post(LOGIN_URL, timeout=30, headers={"Referer": LOGIN_URL}, data={
"csrfmiddlewaretoken": csrf,
"username": USERNAME,
"password": PASSWORD,
"g-recaptcha-response": token,
})
# Many sites answer a failed login with 200 and the form again;
# check for something only a logged-in page shows.
if reply.ok:
log.info("logged in (HTTP %s)", reply.status_code)
else:
log.warning("login refused (HTTP %s): %s", reply.status_code, reply.text[:200])
scheduler = BackgroundScheduler()
scheduler.add_job(
login, "interval", minutes=10,
next_run_time=datetime.now(), # first run now, not ten minutes from now
max_instances=1, # never two solves for the same account at once
coalesce=True, # after a stall, run once, not once per missed slot
misfire_grace_time=None, # a late run still runs instead of being dropped
)
scheduler.start()
try:
while True:
time.sleep(1) # Ctrl+C lands here at once, on any Python version
except KeyboardInterrupt:
log.info("stopping; a solve in flight finishes first")
scheduler.shutdown()它使用 BackgroundScheduler 加一个 sleep 循环,而不是 BlockingScheduler,原因只有在 Windows 上才会显现。在 Python 3.13 或更早版本上使用 BlockingScheduler 时,主线程等待的是一个 Ctrl+C 无法中断的锁,所以 KeyboardInterrupt 要等到下一次计划唤醒时才会到达。间隔为十分钟时,这可能就是十分钟。Python 3.14 在 Windows 上修复了这个问题,但这个循环在所有版本上都能用:Ctrl+C 会立刻中断 time.sleep。
停止时仍会等待正在进行的识别。进程退出之前,Python 会等待线程池的工作线程结束,所以正在运行的识别会先完成。无论你是否调用 scheduler.shutdown(),都是如此;有了第 2 步的超时,这段等待最长大约五分半钟。再按一次 Ctrl+C 也不会缩短它。
会话在提交表单时带上 Referer 请求头,因为 requests 自己不发送 Referer,而 Django 这类框架会拒绝没有 Referer 的 HTTPS 表单提交。会话还会保留与 CSRF 字段对应的 cookie。要在提交之前立刻识别:reCAPTCHA token 在签发两分钟后就会过期。隐形小组件、Enterprise key 和其他变体,详见 用 Python 识别 reCAPTCHA v2 的指南.
同一个调度计划下的多个账号
每个账号一个作业就很好用,再配一个共享的 CapSkip 客户端,它可以安全地在多个线程中使用。把线程池的大小设成和账号数量一样。
# pip install "apscheduler<4" capskip
import logging
from datetime import datetime
from apscheduler.executors.pool import ThreadPoolExecutor
from apscheduler.schedulers.blocking import BlockingScheduler
from capskip import CapSkip
logging.basicConfig(level=logging.INFO)
solver = CapSkip(host="127.0.0.1", port=8080) # one client is safe to share across threads
ACCOUNTS = [f"user{i:02d}" for i in range(20)]
def login(account):
token = solver.recaptcha(sitekey="YOUR_SITEKEY", url="https://example.com/login")["code"]
logging.info("%s got a token %s", account, token[:12])
scheduler = BlockingScheduler(executors={"default": ThreadPoolExecutor(max_workers=20)})
for account in ACCOUNTS:
scheduler.add_job(login, "interval", minutes=10, args=[account], id=account,
next_run_time=datetime.now(), misfire_grace_time=None)
scheduler.start()默认线程池有 10 个线程。当 20 个作业同时到期时,有 10 次运行要等待线程,晚了 7.8 秒才开始,结果在默认 1 秒的宽限时间下被丢弃。一半账号始终没有登录,只有那些错过运行的警告提示了这一点。把线程池设为 20 个线程,或者把 misfire_grace_time 设为 None,就能解决。
CapSkip 自己也有一个上限。它的 reCAPTCHA 设置默认允许同时进行 10 个识别(即 Max. Threads),之后的任务要等待空闲线程。在我们用 20 个线程的测试中,10 个识别用了 7.8 秒,另外 10 个用了 12.8 秒,因为它们为 CapSkip 等了 5 秒。如果你的机器有余力,就在 CapSkip 中调高 Max. Threads;如果没有,就用 jitter 把各个账号错开。
用 asyncio 代替线程
如果你其余的代码是异步的,就使用 AsyncIOScheduler 配合 AsyncCapSkip,也就是 SDK 原生的异步客户端。协程作业在事件循环上运行,max_instances 的作用和之前完全一样。
# pip install "apscheduler<4" capskip
import asyncio
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from capskip import AsyncCapSkip
solver = AsyncCapSkip(host="127.0.0.1", port=8080)
async def solve_login_captcha():
result = await solver.recaptcha(sitekey="YOUR_SITEKEY", url="https://example.com/login")
print("token:", result["code"][:20], "...")
async def main():
scheduler = AsyncIOScheduler()
scheduler.add_job(solve_login_captcha, "interval", minutes=10)
scheduler.start()
await asyncio.Event().wait() # keep the event loop running
asyncio.run(main())有两点不同。AsyncCapSkip 基于 httpx,而 httpx 的默认超时是 5 秒,所以即使不用第 2 步的补丁,卡住的 CapSkip 也会以异常告终:如果卡住的是提交,5 秒后结束;如果卡住的是轮询,在 recaptchaTimeout 到期时结束。它抛出的 NetworkException 消息可能为空,所以也要把异常类型记进日志。另外,停止时会取消正在进行的识别,而不是等它完成:这时作业会把 CancelledError 记录为它的异常。traceback 里还会在它上方显示一个 NetworkException,那只是 SDK 表示尚未就绪的信号,并不是网络故障。Ctrl+C 不到一秒就能退出。
在另一台机器上运行 CapSkip
调度器和识别工具不必在同一台电脑上。在连接设置里把 CapSkip 切换到 Server 模式,它就会监听你的网络地址或公网 IP。然后让客户端指向那里。
# Replace the solver line with this when CapSkip runs in Server mode # on another Windows machine on your network. solver = CapSkip(apiKey="YOUR_API_KEY", host="192.168.1.50", port=8080)
如果链路要经过公网,请使用静态公网 IP,并打开 API 密钥校验,在客户端中使用同一个密钥。Server 模式启动时,CapSkip 会向所有地址开放端口,所以要想只让你预期的机器访问,就为其他地址添加一条 Windows Firewall 阻止规则。识别工具仍然是你自己的 Windows 机器,识别也仍然不计量。
APScheduler 验证码识别的常见错误
| 你所看到的 | 原因 | 修复 |
|---|---|---|
| 偶尔出现 Skipped: maximum number of running instances reached (1) | 某次识别耗时超过了间隔 | 无需处理,或者把间隔调长 |
| 每次运行都出现同样的跳过警告,且没有任何识别 | 某次识别卡在一个没有超时的 HTTP 调用上 | 像第 2 步那样给 SDK 设置超时 |
| 出现警告:Run time of job was missed by 若干秒 | 这次运行晚了 1 秒以上才开始,因此被丢弃 | 把 misfire_grace_time 设为 None |
| 一半账号始终没有运行 | 同时到期的作业数超过了默认线程池的 10 个线程 | 按作业数量设定线程池大小 |
| 按 Ctrl+C 好几分钟都没反应 | 在 Windows 上配合 Python 3.13 或更早版本使用 BlockingScheduler | 改用 BackgroundScheduler 加 sleep 循环,或者使用 Python 3.14 |
| 作业抛出异常:ApiException ERROR_CAPTCHA_UNSOLVABLE | CapSkip 无法识别这个任务 | 在作业中捕获 CapSkipError;下一次运行会再试 |
| NetworkException: Read timed out. (read timeout=30) | CapSkip 接受了任务后停止应答 | 检查 CapSkip 是否有响应;下一次运行会再试 |
| TimeoutException: timeout 300.0 exceeded | SDK 轮询期间,CapSkip 停止应答或被关闭 | 检查 CapSkip 是否在运行;下一次运行会再试 |
| NetworkException,提示目标计算机积极拒绝了连接 | CapSkip 没有在运行,或者主机或端口不对 | 启动 CapSkip,并对照它的连接设置检查地址 |
| ModuleNotFoundError: No module named apscheduler.schedulers | 安装的是 APScheduler 4,比如某个预发布版 | 安装低于 4 的 APScheduler 版本 |
| 站点拒绝了 token | token 已过期,或者提交时漏掉了 cookie、某个隐藏字段或 Referer | 在提交之前立刻识别,并使用获取页面的那个会话 |
常见问题
可以把这些作业存到数据库里吗?
可以,用 SQLAlchemyJobStore,但要留意保存的是什么。作业的函数是按引用存储的,所以它必须位于一个可导入的模块中:lambda 会因 ValueError 失败,而来自你以 __main__ 方式运行的脚本中的函数,会在另一个程序加载这个存储时被移除。通过模块变量传递 CapSkip 客户端,而不是通过作业的 args。放在 args 中的客户端会连同它的 host、端口和 API 密钥一起被 pickle 进数据库,保存下来的作业会一直使用那个地址,直到你用同一个 id、并把 replace_existing 设为 True 重新添加这个作业。无论如何都要给作业一个 id:没有 id 的话,程序每启动一次就会多添加一份副本。
为什么不直接在 recaptcha 调用上设置超时?
因为 SDK 在那里不接受超时参数。在 capskip 1.3.0 中,给 solver.recaptcha 传入 timeout 参数会引发 TypeError,而客户端上的 recaptchaTimeout 设置只限制轮询循环,并且只在两次请求之间检查。两者都无法限制单次 HTTP 调用,这就是第 2 步要直接给 requests 调用打补丁的原因。
什么时候任务队列比 APScheduler 更合适?
当工作是由其他代码创建的,或者每一项工作都必须被保存下来,并在进程崩溃时重试的时候。APScheduler 在你自己的程序内部按定时器运行作业。RQ 这样的队列则把每个作业存进 Redis,在独立的 worker 中运行,而且它有自己的作业超时需要提前规划,详见 在 RQ 作业中识别验证码的指南.
调度器能和 CapSkip 运行在不同的机器上吗?
可以。在连接设置里把 CapSkip 切换到 Server 模式,让它监听网络地址而不是回环地址,并把这个地址作为客户端的 host 传入。这样,调度器就能从任何可以访问该端口的机器上,通过同一套 HTTP API 调用它。为不该访问它的地址添加一条 Windows Firewall 阻止规则。识别工具仍然运行在你自己的硬件上,没有任何一次识别会经过第三方服务。
简短版结论
APScheduler 验证码作业需要的不只是识别本身。给 SDK 的 HTTP 调用设置超时,因为在 max_instances 为 1 时,一次卡住的识别就会跳过之后的每一次运行。把 misfire_grace_time 设为 None 并保持 coalesce 开启,这样迟到的运行会执行一次,而不是被丢弃。按你的作业数量设定线程池大小,在作业内部捕获 CapSkipError,如果你在 Windows 上使用 Python 3.13 或更早版本并用 Ctrl+C 停止它,就使用 BackgroundScheduler 加 sleep 循环。官方的 APScheduler 3.x 用户指南 介绍了这里用到的每一个选项。
- Python 包能处理的所有验证码类型: Python 验证码识别页面.
- 复选框 reCAPTCHA 如何运作、识别工具覆盖哪些内容: reCAPTCHA v2 识别页面.
一个每十分钟运行一次的作业,一个月会做四千多次识别。用在你自己的 Windows 机器上运行的 验证码识别工具 来完成这些识别,这个数字就永远不会变成账单。
