如何在 pytest 中识别验证码,又不让整个运行卡住

要在 pytest 中识别验证码,就在 conftest.py 里为每个会话创建一个 CapSkip 客户端,并在每个测试内部进行识别,因为一个 token 只能用一次。CapSkip 没有运行时,跳过这些验证码测试。然后给 SDK 设置它自己的超时。在 Windows 上,pytest-timeout 只能使用它的 thread 方法,而它一旦触发,就会结束整个测试运行。Python SDK 不设置 HTTP 超时,所以一个毫无回应的 CapSkip 会让测试停在那里,而且没有任何错误。CapSkip 运行在你自己的 Windows PC 上,所以一个每晚识别几百次的测试套件没有按次识别的费用。这套 pytest 验证码识别方案会逐一讲到这些陷阱,另外还有用 xdist 并行运行和异步测试。
你需要什么
- 在 Windows PC 上运行的 CapSkip,并已开启 reCAPTCHA 识别功能。 设置指南 会带你完成首次启动的每一步。
- Python 3.10 或更高版本。capskip 1.3.0 包和 pytest 9 都要求它。下面的所有内容都在 Windows 11 上用 Python 3.14.0 运行过,使用 pytest 9.1.1、pytest-timeout 2.4.0、pytest-xdist 3.8.0、pytest-asyncio 1.4.0 和 requests 2.34.2。连接被拒绝的耗时数据在 3.13.7 上结果相同。
- 一个 PowerShell 提示符。这些命令使用 PowerShell 语法。
- 一个你自己拥有的预发布站点(staging site),登录表单里带有 reCAPTCHA v2 复选框。示例登录的是一个带有 CSRF token 的 Django 风格表单。请把字段名改成与你的站点一致。
- 识别工具的地址。在 Local 模式下,CapSkip 只在 127.0.0.1 上响应,仅供本机使用。在 Server 模式下,它监听你的网络地址或公网 IP,这样另一台机器上的测试就能通过 API 调用它。两种模式的设置位置都是 连接设置.
下面的测量数据来自同一台 PC 上的一个模拟 CapSkip 和一个模拟登录页面,配合真实的 pytest 和真实的 SDK 使用。pytest 的行为和 SDK 的行为在你自己的站点上同样成立。识别耗时、线程上限,以及登录页面的接受和拒绝规则,则只属于这两个模拟程序本身。
第 1 步:创建项目
把所有东西放在同一个项目文件夹里:conftest.py、tokens.py 和各个测试文件。使用虚拟环境。下面的每条命令都通过路径运行虚拟环境自己的 Python,所以你不需要激活它。用 python -m pytest 启动 pytest。这种写法会把当前文件夹加入导入路径。如果你把 conftest.py 移进 tests 文件夹,就让 tokens.py 留在项目文件夹里。这时 pytest.exe 会失败,报出 ModuleNotFoundError: No module named ‘tokens’,而 python -m pytest 能正常工作。
# Windows PowerShell, in your project folder python -m venv .venv .venv\Scripts\python -m pip install pytest "capskip==1.3.0" requests
第 2 步:整个测试运行共用一个 CapSkip 客户端
把它保存为 conftest.py。它从环境变量中读取 CapSkip 的设置,因为 SDK 自己不会读取它们,而且这些默认值让它不需要任何配置就能运行。只有当你在 CapSkip 中开启了 API key validation 时,CAPSKIP_API_KEY 才有意义。这个 fixture 为整个会话创建一个客户端,同时还会检查 CapSkip 是否在运行。
# conftest.py (next to your tests): pip install pytest capskip requests
import os
import socket
import pytest
from capskip import CapSkip
# The SDK reads no environment variables, so the fixture reads them.
HOST = os.environ.get("CAPSKIP_HOST", "127.0.0.1")
PORT = int(os.environ.get("CAPSKIP_PORT", "8080"))
API_KEY = os.environ.get("CAPSKIP_API_KEY", "capskip")
@pytest.fixture(scope="session")
def capskip_client():
# CapSkip has no ping endpoint, so try a plain TCP connect. Use the IP,
# not "localhost": a refused connect takes 2 s, or 4 s for localhost.
try:
socket.create_connection((HOST, PORT), timeout=3).close()
except OSError as exc:
pytest.skip(f"CapSkip is not running on {HOST}:{PORT}: {exc}")
# One client for the whole run. A solve gives up after 90 s of polling.
return CapSkip(apiKey=API_KEY, host=HOST, port=PORT, recaptchaTimeout=90)CapSkip 没有 ping 端点,所以用一次普通的 TCP 连接来询问是否有程序在监听,而且它不会发送任何识别任务。如果没有,这个 fixture 就跳过测试,而不是让它们失败。CapSkip 关闭时,在 Windows 上连接 127.0.0.1 被拒绝用了 2.05 秒,连接名称 localhost 则用了大约 4.1 秒。跳过的结果会被缓存,所以整个会话只付出一次这个代价:两个文件中的四个验证码测试总共用了 2.05 秒。
请用 IP。即使 CapSkip 正在运行,名称 localhost 也有第二项代价。在只监听 IPv4 的情况下,两个登录测试用 localhost 花了 10.3 秒,用 127.0.0.1 花了 3.75 秒。其中大约 2 秒是连接检查时一次性付出的,每次识别还要多花 1 到 2.5 秒。
第 3 步:识别一次并重试一次的辅助函数
把它保存为 tokens.py。有些识别会失败,重新尝试一次可能就成功。SDK 会抛出一个消息为 ERROR_CAPTCHA_UNSOLVABLE 的 ApiException,除非你在 CapSkip 里设置了 Error Replacement 文本。那样的话,消息里带的是你设置的文本,下面的检查就永远匹配不上。辅助函数只重试这一种失败。CapSkip 拒绝的提交,比如 ERROR_GOOGLEKEY 或 ERROR_PAGEURL,会立即抛出异常。被 Google 拒绝的 sitekey 返回的也是 UNSOLVABLE,所以它会先尝试两次才失败。请参阅 ERROR_CAPTCHA_UNSOLVABLE 了解这个错误的含义。
# tokens.py: the helper your tests (and your app) call
from capskip import ApiException
def fresh_token(solver, sitekey, url, attempts=2):
"""Solve a reCAPTCHA v2 and return the token string."""
for attempt in range(attempts):
try:
return solver.recaptcha(sitekey=sitekey, url=url)["code"]
except ApiException as exc:
# UNSOLVABLE is how CapSkip reports a solve that failed this
# time. A refused submit (ERROR_GOOGLEKEY, ERROR_PAGEURL) will
# fail again, so it raises at once.
last = attempt == attempts - 1
if last or "ERROR_CAPTCHA_UNSOLVABLE" not in str(exc):
raise第 4 步:每个测试一个 fixture,以及一个标记
把下一个代码块加到 conftest.py 的末尾。它是一个工厂 fixture,而不是一个现成的 token。每次调用它都是一次新的识别,每个测试都要请求属于自己的那一个,因为 reCAPTCHA token 只会被接受一次。Python 允许在文件中间写导入语句,所以导入行可以留在代码块放置它的位置。
from tokens import fresh_token
@pytest.fixture
def solve_recaptcha(capskip_client):
# A factory, not a token: every call is a new solve, because a
# reCAPTCHA token is accepted once. Function scope, so each test
# asks for its own.
def solve(sitekey, url):
return fresh_token(capskip_client, sitekey, url)
return solve然后把下面的标记钩子加到 conftest.py。它注册了一个名为 captcha 的标记,这样你就可以只运行不需要 CapSkip 的快速测试,也可以运行需要它的实时测试。在开启严格标记选项(第 6 步会把它打开)的情况下,任何你没有注册的标记都是错误。注册 captcha 能让真正的标记继续有效,并把拼写错误变成错误。
def pytest_configure(config):
# Tell pytest the marker exists, so --strict-markers accepts it and a
# misspelling such as "captcah" is an error instead of a silent mark.
config.addinivalue_line(
"markers", "captcha: needs a running CapSkip and solves a real CAPTCHA"
)第 5 步:编写登录测试
把接下来的两个代码块保存为同一个文件 test_login.py。这第一个代码块是你的,不是 CapSkip 的。它打开登录页面,读取 CSRF token 和 sitekey,并提交表单。requests 在提交表单时不发送 Referer。Django 这类严格的站点在 HTTPS 下会拒绝没有 Referer 的提交,模拟程序也一样,返回了 403。所以辅助函数自己设置它。请按你页面的标记调整那两个正则表达式和字段名,并把测试账号的电子邮件和密码填进 post_form。
# test_login.py: set LOGIN_URL to your own login page
import os
import re
import pytest
import requests
LOGIN_URL = os.environ.get("LOGIN_URL", "https://example.com/login")
def open_form(session):
html = session.get(LOGIN_URL, timeout=15).text
csrf = re.search(r'name="csrfmiddlewaretoken" value="([^"]+)"', html)
sitekey = re.search(r'data-sitekey="([^"]+)"', html)
return csrf.group(1), sitekey.group(1)
def post_form(session, csrf, token, password="secret"):
# requests sends no Referer, and a Django site refuses an HTTPS post
# that has none, so set it by hand.
return session.post(LOGIN_URL, timeout=15, headers={"Referer": LOGIN_URL}, data={
"csrfmiddlewaretoken": csrf,
"email": "[email protected]",
"password": password,
"g-recaptcha-response": token,
})测试把识别放在最后并立即提交,因为 token 大约两分钟后就会过期。 reCAPTCHA token 过期指南 对这一方面有详细说明。把 Welcome、403 和错误文本改成你自己的站点显示的内容。固定的用户名在第二次夜间运行时会冲突,所以如果你的表单会创建账号,每次都要用一个新的用户名。
@pytest.mark.captcha
def test_login(solve_recaptcha):
session = requests.Session()
csrf, sitekey = open_form(session)
token = solve_recaptcha(sitekey, LOGIN_URL) # solve last, post at once
resp = post_form(session, csrf, token)
assert resp.status_code == 200 and "Welcome" in resp.text
@pytest.mark.captcha
def test_wrong_password_is_refused(solve_recaptcha):
session = requests.Session()
csrf, sitekey = open_form(session)
token = solve_recaptcha(sitekey, LOGIN_URL) # a new solve, not test 1's
resp = post_form(session, csrf, token, password="wrong")
assert resp.status_code == 403
assert "Invalid email or password" in resp.text第 6 步:先运行快速测试,再运行实时测试
第一行让测试指向你的登录页面。第二行让拼错的标记变成错误。它设置了一个供 pytest 读取额外选项的环境变量,并且在这个 PowerShell 会话的余下时间里一直有效。第三行运行除验证码测试之外的所有测试。第四行只运行实时测试。跳过开关会打印测试被跳过的原因,时长开关会列出最慢的测试。
# Windows PowerShell: your login page, then make a misspelled marker an error $env:LOGIN_URL = "https://staging.example.com/login" $env:PYTEST_ADDOPTS = "--strict-markers" .venv\Scripts\python -m pytest -m "not captcha" # fast suite, no CapSkip .venv\Scripts\python -m pytest -m captcha -rs -v --durations=5 # live tests
此时,快速运行找不到任何要运行的测试,pytest 以退出码 5 退出。等你保存了后面单元测试一节中的 test_unit.py,它就有测试了。到那时它完全不需要识别工具:它的四个测试在 CapSkip 关闭的情况下用了 0.02 秒就通过了。实时运行被跳过,输出类似下面这样的一行,指向的是测试本身,而不是 conftest.py:
- SKIPPED [1] test_login.py:29: CapSkip is not running on 127.0.0.1:8080: [WinError 10061] No connection could be made because the target machine actively refused it
被跳过的测试套件以退出码 0 结束,而 pytest 9.1.1 没有任何开关能把跳过变成失败。如果你的构建必须有 CapSkip,就用 junitxml 选项写出一个 junit 文件,并在其中的跳过数大于零时让构建失败。在开启严格选项的情况下,把测试里的 captcha 打成 captcah,会以退出码 2 中止收集,并给出消息 ‘captcah’ not found in `markers` configuration option。不开严格选项的话,这个拼写错误只是一条警告,而那个测试会混进快速测试里运行。
在每个测试内部识别
不要在一个 fixture 里识别一次再共享这个 token。测试中,一个模块作用域的 fixture 只识别了一次,两个测试提交了同一个 token。第一个被接受了。第二个失败了,错误是模拟程序的 reCAPTCHA failed: token already used。真实站点的措辞会不同。哪个测试输掉取决于顺序,所以失败会到处移动,看起来像是不稳定。在 xdist 下情况更糟,并行一节会说明这一点。下面是这个错误的完整写法,方便你认出它。看过它失败之后就把这个文件删掉,否则它会让之后的运行随机失败或通过。
# test_shared_token.py: the mistake, so you can see it fail
import pytest
import requests
from test_login import LOGIN_URL, open_form, post_form
@pytest.fixture(scope="module")
def shared_token(capskip_client):
csrf, sitekey = open_form(requests.Session())
return capskip_client.recaptcha(sitekey=sitekey, url=LOGIN_URL)["code"]
@pytest.mark.captcha
@pytest.mark.parametrize("n", [1, 2])
def test_each_login_reuses_one_token(shared_token, n):
session = requests.Session()
csrf, sitekey = open_form(session)
resp = post_form(session, csrf, shared_token)
assert resp.status_code == 200, resp.text # n=2 fails: used already每个测试识别一次,每次要花几秒钟。不需要验证码本身的测试,就不应该请求这个 fixture。后面的替身识别工具负责测试它周围的逻辑。
让挂起的识别失败,而不是中止整个运行
这一部分在 Windows 上有所不同。pytest-timeout 插件有两种方法。signal 方法需要 SIGALRM,而 Windows 没有它,所以在那里不可用。要求使用它,结果以 INTERNALERROR 和 AttributeError: module ‘signal’ has no attribute ‘SIGALRM’ 结束。thread 方法是默认方法,也是唯一可用的方法。下面是一个测试挂起时它的表现,限制为 10 秒,运行中有四个测试:
- 它打印了主线程的堆栈,大约 100 行,并在 12.5 秒时以退出码 1 结束了进程。
- 挂起的那个测试没有得到 PASSED 或 FAILED 行。
- 没有打印摘要,也没有写出 junit 文件。
- 它后面的两个测试从未运行。
使用 xdist 时,损失要小一些。只有运行那个测试的 worker 会死掉。该测试被报告为 FAILED,信息为 worker gw1 crashed while running,后面跟着该测试的名称,随后一个替代的 worker 启动,运行最终带着摘要和退出码 1 结束。
所以 thread 超时只是最后的手段,而不是测试超时。你希望 SDK 先失败,抛出普通异常,这样测试失败,运行继续。Python SDK 1.3.0 让这件事变得很难。它调用 requests 时不带超时,所以一个接受了调用却始终不应答的 CapSkip 会让它挂起。不做任何改动的话,一次毫无回应的提交在我们于 45 秒时把它停掉的时候仍然被阻塞着。它的 recaptchaTimeout 只在两次轮询之间检查,而一次挂起的轮询会被当作尚未就绪。
修复办法是把 SDK 使用的两个请求函数换成带超时的版本。把下一个代码块加到 conftest.py。它会深入 SDK 内部,所以要把 capskip 固定在 1.3.0 版本。hasattr 那一行让它在以后的版本不再使用 requests 时什么也不做。它不覆盖以 URL 形式给出的图片验证码的下载,因为 SDK 用它自己的调用来完成那一步。
import functools
import types
import capskip.api
import requests
# capskip 1.3.0 sets no HTTP timeout, so a CapSkip that accepts a call
# and never answers would hang the test. Give every SDK call 5 s to
# connect and 30 s to answer. This swaps a name inside the SDK, so pin
# capskip==1.3.0 and do nothing if the SDK stops using requests.
if hasattr(capskip.api, "requests"):
capskip.api.requests = types.SimpleNamespace(
RequestException=requests.RequestException,
post=functools.partial(requests.post, timeout=(5, 30)),
get=functools.partial(requests.get, timeout=(5, 30)),
)打上补丁之后,一个从未应答提交的 CapSkip,在 30.0 秒时引发了 NetworkException,消息为 Read timed out. (read timeout=30)。pytest 测试在 30.8 秒时失败,运行保持完好。毫无回应的轮询则不同。SDK 把它当作尚未就绪吞掉了,默认的 300 秒 recaptchaTimeout 使它在 333.0 秒时以 TimeoutException 结束。这就是第 2 步传入 recaptchaTimeout=90 的原因。用 90 的话,测试在 92.6 秒时失败。
时限最多可能被超出大约 35 秒。它只在两次轮询之间检查,一次轮询可能用满它的 30 秒,而且 SDK 在检查之前最多还要等待 5 秒。限制为 45 时,失败出现在 60.8 秒,限制为 20 时则是 30.3 秒。所以在这些数值下,一次尝试的最坏情况大约是 125 秒:90 秒用于轮询,外加一次卡住的轮询。第 3 步的辅助函数最多尝试两次,所以最坏情况大约是 250 秒。把 pytest-timeout 的限制设得高于这个值。下一个代码块会安装插件并设为 300 秒。这个环境变量会替换第 6 步的那个,所以它把严格选项又写了一遍。你也可以改为把同样两个选项以 timeout 和 timeout_method 的名字写进 pytest 的 ini 文件。变量只是更方便粘贴。
# Windows PowerShell: pytest-timeout has only the thread method here. # 300 s is above two attempts at the slowest SDK failure (about 125 s each), # so the SDK fails first. .venv\Scripts\python -m pip install pytest-timeout $env:PYTEST_ADDOPTS = "--strict-markers --timeout=300 --timeout-method=thread"
用 xdist 并行运行测试
识别很慢,而且大部分时间在等待,所以 xdist 有帮助。每个 xdist worker 是一个独立的进程。因此 session fixture 在每个 worker 中各运行一次,每个 worker 构建自己的客户端。四个 worker、八个测试时,运行显示了四个进程 ID 和四个客户端对象。不用 xdist 时,两者各有一个。测试会写出 workers-0.log 到 workers-7.log,用 Get-Content workers-*.log 可以查看它们。每个 worker 一个客户端没有问题,因为客户端不保存任何需要 worker 共享的东西。
# test_workers.py: shows one client per worker process
import os
from pathlib import Path
import pytest
@pytest.mark.captcha
@pytest.mark.parametrize("n", range(8))
def test_which_client(capskip_client, worker_id, n):
line = f"{worker_id} pid={os.getpid()} client={id(capskip_client)}"
Path(f"workers-{n}.log").write_text(line)worker 的数量要自己选。auto 设置指的是 CPU 数量,而不是 CapSkip 能同时识别的数量。它在测试用的 PC 上给出了 24 个 worker。四个单元测试用它花了大约 4 秒,用四个 worker 则大约 1.1 秒,原因是 worker 的启动开销。CapSkip 自己的上限是 reCAPTCHA 的 Max. Threads 设置,默认为 10。这张表来自对一个设有该上限、识别耗时 5 秒的模拟程序进行的 24 次登录。
| Worker 数量 | 运行时间 | 同时识别数 |
|---|---|---|
| 1 | 189.6 s | 1 |
| 4 | 49.1 s | 4 |
| 10 | 26.1 s | 10 |
| 12 | 24.1 s | 10 |
| 24 | 23.1 s | 10 |
超过 10 个 worker 后,收益很小:10 个 worker 用 26.1 秒,24 个用 23.1 秒。多出来的任务在 CapSkip 内部等待空闲线程,等待超过 Wait Timeout 的任务会失败。在一个缩小规模的测试中,八个测试同时对一个上限为 2、等待上限为 6 秒、识别耗时 4 秒的模拟程序运行。四个任务以 ERROR_CAPTCHA_UNSOLVABLE 失败。tokens.py 里的重试把它们重新排队,八个测试在 22.3 秒内全部通过。这就是需要重试的第二个原因。
每个测试在 5 秒的识别下用了大约 7.9 秒。SDK 在 0、0.25、0.75、1.75、3.75 和 7.75 秒时轮询,所以它要到下一次轮询才发现识别已经完成。xdist 的安装和命令在下面。
# Each xdist worker is its own process, so each builds its own client. # Pick the number yourself: -n auto means CPU count, not solver capacity. .venv\Scripts\python -m pip install pytest-xdist .venv\Scripts\python -m pytest -m captcha -n 4 -v
还有一个陷阱。在 xdist 下,前面那个共享 token 的错误可能会通过。模块作用域的 token 在每个 worker 里识别一次,所以只有两个测试恰好落在同一个 worker 上时才会冲突。一个在有些运行中通过、在另一些运行中失败的测试,就是提示你去查找共享 token 的线索。
使用 AsyncCapSkip 的异步测试
Python SDK 有一个真正的异步客户端 AsyncCapSkip。它基于 httpx。把这个 fixture 加到 conftest.py。它请求 capskip_client,所以跳过检查仍然适用。异步客户端不持有事件循环,所以普通 fixture 就够了,测试运行在默认的函数作用域事件循环上。
from capskip import AsyncCapSkip
@pytest.fixture(scope="session")
def async_capskip_client(capskip_client):
# Asking for capskip_client keeps the skip-when-closed check. The
# async client holds no event loop, so a plain fixture is enough.
return AsyncCapSkip(apiKey=API_KEY, host=HOST, port=PORT, recaptchaTimeout=90)pytest-asyncio 1.4.0 运行在 strict 模式下,而 pytest 9 会让缺少该标记的异步测试失败,报错 Failed: async def functions are not natively supported。把下一个代码块保存为 test_async.py。把 YOUR_SITEKEY 改成页面的 sitekey。
# test_async.py: .venv\Scripts\python -m pip install pytest-asyncio
import asyncio
import pytest
from test_login import LOGIN_URL
@pytest.mark.captcha
@pytest.mark.asyncio # needed: pytest-asyncio runs in strict mode
async def test_five_solves_at_once(async_capskip_client):
solves = [
async_capskip_client.recaptcha(sitekey="YOUR_SITEKEY", url=LOGIN_URL)
for _ in range(5)
]
results = await asyncio.gather(*solves)
assert len({r["code"] for r in results}) == 5 # five different tokens不要指望它能按比例线性扩展。对一个识别耗时 1.5 秒的模拟程序,用 gather 同时进行 1、5、10 和 25 次识别,分别用了 2.6、2.5、4.6 和 9.8 秒。客户端为每次调用都打开一个新的 httpx 连接,这每次都会让事件循环忙一会儿。超时一节中的补丁不适用于这个客户端。它的限制是 httpx 的默认值:每个请求 5 秒,而不是每次识别 5 秒,所以一次 12 秒的识别仍然成功,在 14.1 秒时完成。一次毫无回应的提交在 5.2 秒时引发了消息为空的 NetworkException。一次毫无回应的轮询在 24.7 秒时以 TimeoutException 结束,限制是 20。关于 Python 中并发的更多内容,请参阅 在 Python 中使用 asyncio 并行识别验证码.
不需要识别工具的单元测试
你的大多数测试不应该需要 CapSkip。测试你自己调用识别工具的代码时,可以用一个替身。它的结果必须与真实的完全一致。SDK 返回一个带 captchaId 和 code 的普通 dict,所以替身也这样做。最后一个测试展示了另一种做法:构建真实的客户端(在你识别之前它不会打开任何连接),并用 monkeypatch 替换其中一个方法。这四个测试在 CapSkip 关闭时全部通过。把这个代码块保存为 test_unit.py。
# test_unit.py: runs with CapSkip closed
import pytest
from capskip import ApiException, CapSkip
from tokens import fresh_token
URL = "https://example.com/login"
class FakeSolver:
"""Same call and same result shape as CapSkip.recaptcha()."""
def __init__(self, *outcomes):
self.outcomes = list(outcomes)
self.calls = 0
def recaptcha(self, sitekey, url, **kwargs):
self.calls += 1
outcome = self.outcomes.pop(0)
if isinstance(outcome, Exception):
raise outcome
return {"captchaId": "1001", "code": outcome}
def test_returns_the_code():
assert fresh_token(FakeSolver("tok-1"), "KEY", URL) == "tok-1"
def test_retries_once_when_unsolvable():
fake = FakeSolver(ApiException("ERROR_CAPTCHA_UNSOLVABLE"), "tok-2")
assert fresh_token(fake, "KEY", URL) == "tok-2"
assert fake.calls == 2
def test_does_not_retry_a_bad_sitekey():
fake = FakeSolver(ApiException("ERROR_GOOGLEKEY"), "never-used")
with pytest.raises(ApiException):
fresh_token(fake, "KEY", URL)
assert fake.calls == 1
def test_real_client_with_monkeypatch(monkeypatch):
solver = CapSkip() # opens no connection until a solve is called
fake = {"captchaId": "1", "code": "tok-3"}
monkeypatch.setattr(solver, "recaptcha", lambda **kw: fake)
assert fresh_token(solver, "KEY", URL) == "tok-3"对另一台机器上的 CapSkip 运行测试
测试和识别工具不必共用一台 PC。在连接设置里把 CapSkip 切换到 Server 模式,它就会监听你的网络地址或公网 IP。建议使用静态公网 IP,以便稳定地远程访问。然后设置那三个变量,同一套测试不用修改就能运行。识别工具仍然是你自己的 Windows 机器,识别也仍然不计量。
# CapSkip on another Windows machine in Server mode: set these first $env:CAPSKIP_HOST = "192.168.1.50" $env:CAPSKIP_PORT = "8080" $env:CAPSKIP_API_KEY = "your-key" # only if API key validation is on .venv\Scripts\python -m pytest -m captcha -rs
有两点要注意。SDK 通过普通 HTTP 与 CapSkip 通信,所以 API 密钥是以明文传输的。把这条链路放在专用网络或 VPN 里,并开启 API key validation。另外,Server 模式启动时,CapSkip 会向所有地址开放端口。要想只让你预期的机器访问,就为其他地址添加一条 Windows Firewall 阻止规则。本指南没有运行远程设置。只读取了这些变量。
完整的 conftest.py
下面是第 2 步和第 4 步的 conftest.py,加上超时补丁和异步 fixture,合并在一个文件里。它取代你逐个添加的那些代码块,所以请先把那些删掉。
# conftest.py, complete: pip install pytest capskip==1.3.0 requests
import functools
import os
import socket
import types
import capskip.api
import pytest
import requests
from capskip import AsyncCapSkip, CapSkip
from tokens import fresh_token
# The SDK reads no environment variables, so this file reads them.
HOST = os.environ.get("CAPSKIP_HOST", "127.0.0.1")
PORT = int(os.environ.get("CAPSKIP_PORT", "8080"))
API_KEY = os.environ.get("CAPSKIP_API_KEY", "capskip")
# capskip 1.3.0 sets no HTTP timeout. Give every SDK call 5 s to connect
# and 30 s to answer. Pin capskip==1.3.0: this swaps a name inside it.
if hasattr(capskip.api, "requests"):
capskip.api.requests = types.SimpleNamespace(
RequestException=requests.RequestException,
post=functools.partial(requests.post, timeout=(5, 30)),
get=functools.partial(requests.get, timeout=(5, 30)),
)
def pytest_configure(config):
# Register the marker so --strict-markers accepts it.
config.addinivalue_line(
"markers", "captcha: needs a running CapSkip and solves a real CAPTCHA"
)
@pytest.fixture(scope="session")
def capskip_client():
# No ping endpoint, so a plain TCP connect. Use the IP, not "localhost".
try:
socket.create_connection((HOST, PORT), timeout=3).close()
except OSError as exc:
pytest.skip(f"CapSkip is not running on {HOST}:{PORT}: {exc}")
return CapSkip(apiKey=API_KEY, host=HOST, port=PORT, recaptchaTimeout=90)
@pytest.fixture
def solve_recaptcha(capskip_client):
# A factory: every call is a new solve, and a token works once.
def solve(sitekey, url):
return fresh_token(capskip_client, sitekey, url)
return solve
@pytest.fixture(scope="session")
def async_capskip_client(capskip_client):
return AsyncCapSkip(apiKey=API_KEY, host=HOST, port=PORT, recaptchaTimeout=90)pytest 验证码测试的常见错误
| 你所看到的 | 原因 | 修复 |
|---|---|---|
| SKIPPED: CapSkip is not running on 127.0.0.1:8080 | 那里没有程序在监听,或者 CAPSKIP_HOST 包含 http:// | 启动 CapSkip,检查连接设置,给出不带 http:// 的主机 |
| INTERNALERROR,信息为 no attribute SIGALRM | Windows 上不存在 signal 超时方法 | 使用 thread 方法 |
| 一份堆栈转储,没有摘要,退出码 1 | thread 超时触发了,结束了整个运行 | 让 SDK 先失败,使用 recaptchaTimeout 和请求补丁 |
| worker gw1 crashed while running | thread 超时杀死了一个 xdist worker | 同样的修复,并找出挂起的测试 |
| ‘captcah’ not found in `markers` configuration option | 开启严格选项时拼错的标记 | 改正拼写 |
| ModuleNotFoundError: No module named ‘tokens’ | pytest.exe 不会把项目文件夹放进路径 | 运行 python -m pytest |
| Failed: async def functions are not natively supported | 异步测试没有 asyncio 标记 | 添加该标记 |
| 登录提交返回 403 | requests 没有发送 Referer,而站点会检查它 | 传入 Referer 请求头 |
| 第二个测试失败,第一个通过 | 两个测试提交了同一个 token | 在每个测试内部识别 |
| NetworkException: Read timed out (read timeout=30) | CapSkip 接收了提交,然后毫无回应 | 检查 CapSkip,然后重新运行 |
| 大约 333 秒后出现 TimeoutException | 一次毫无回应的轮询,使用默认的 300 秒限制 | 把 recaptchaTimeout 设为 90 |
| 每次识别都更慢,而第一次检查多花 2 秒 | CAPSKIP_HOST 是 localhost | 使用 127.0.0.1 |
| AttributeError: ‘NoneType’ object has no attribute ‘group’ | LOGIN_URL 处的页面没有 CSRF token 或 sitekey,因为 LOGIN_URL 仍然是示例地址 | 把 LOGIN_URL 设为你自己的登录页面 |
常见问题
托管的 CI runner 能运行这些测试吗?
托管 runner 是别人的机器,所以它无法访问你 PC 上的 127.0.0.1。它需要 Server 模式下 CapSkip 的一个公网地址。这样密钥就会通过普通 HTTP 以明文传输,而且阻止规则无法列出不断变化的 runner 地址。优先使用你自己网络里的自托管 runner,或者使用 VPN,并把 API 密钥保存在 CI 系统的机密存储里。本指南没有测试任何托管 runner。只读取了这些变量。
每次构建都应该运行验证码测试吗?
不用。每个验证码测试都要几秒钟,需要一个运行中的 CapSkip,还会接触真实的预发布站点。有了标记,普通构建可以运行快速测试,计划任务可以运行实时测试。在发布之前,或者每晚运行实时测试。
这能和标准的 unittest 模块一起用吗?
CapSkip 的调用在任何框架里都是一样的。上面的会话客户端、跳过、标记和并行设置都是 pytest 的功能,本指南没有运行过 unittest。请查看你的测试运行器的文档,了解它自己的 fixture、跳过和超时。
需要 Selenium 或 Playwright 吗?
这套方案不需要。它用 requests 提交表单,因此测试的是服务器会检查的登录流程。如果验证码只能在真实的浏览器会话里工作,就从测试里驱动一个浏览器,并把 token 交给页面。识别工具的调用保持不变。
简短版结论
pytest 验证码测试套件需要每个会话一个 CapSkip 客户端,并在每个测试内部做一次新的识别。CapSkip 关闭时跳过,给实时测试加上标记,并让明确指定的 worker 数量保持在 CapSkip 的 10 个线程以内。在 Windows 上,把 SDK 自己的限制设得远低于 pytest-timeout 的限制,这样一次挂起的识别只会让一个测试失败,而不会结束整个运行。
- Python 包能处理的所有验证码类型: Python 验证码识别页面.
- 纯 Python 脚本中的复选框 reCAPTCHA: 在 Python 中识别 reCAPTCHA v2.
- 复选框识别工具是如何工作的: reCAPTCHA v2 识别页面.
一个每晚运行、要识别几百次的测试套件,次数很快就会累积起来。用在你自己的 Windows 机器上运行的 验证码识别工具 方案来完成这些识别,它们就永远不会变成账单。
