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

apscheduler captcha - How to Solve CAPTCHAs in APScheduler Without Skipped Runs

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_UNSOLVABLECapSkip 无法识别这个任务在作业中捕获 CapSkipError;下一次运行会再试
NetworkException: Read timed out. (read timeout=30)CapSkip 接受了任务后停止应答检查 CapSkip 是否有响应;下一次运行会再试
TimeoutException: timeout 300.0 exceededSDK 轮询期间,CapSkip 停止应答或被关闭检查 CapSkip 是否在运行;下一次运行会再试
NetworkException,提示目标计算机积极拒绝了连接CapSkip 没有在运行,或者主机或端口不对启动 CapSkip,并对照它的连接设置检查地址
ModuleNotFoundError: No module named apscheduler.schedulers安装的是 APScheduler 4,比如某个预发布版安装低于 4 的 APScheduler 版本
站点拒绝了 tokentoken 已过期,或者提交时漏掉了 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 用户指南 介绍了这里用到的每一个选项。

一个每十分钟运行一次的作业,一个月会做四千多次识别。用在你自己的 Windows 机器上运行的 验证码识别工具 来完成这些识别,这个数字就永远不会变成账单。