如何在 Python 爬虫中区分验证码与封锁

Python 爬虫拿到异常响应时,首先要问的是:这是验证码还是封锁?因为答案决定了下一步怎么做。验证码可以识别。速率限制需要等待。封锁页面意味着你的地址或指纹被拒绝,任何 token 都改变不了这一点。本指南构建一个约 40 行的分类器,读取状态码、响应头和标记,并返回六个标签之一,然后再构建一个抓取循环,只在识别能起作用的那个标签上才调用 CapSkip。CapSkip 运行在你自己的 Windows PC 上,没有按次识别的费用,所以跳过一次不可能成功的识别,省下的是时间,不是钱。
你需要什么
- 在 Windows PC 上运行的 CapSkip,并已开启 reCAPTCHA 识别功能。 设置指南 详细介绍了首次启动的步骤。
- Python 3.10 或更高版本。下面的代码在 Python 3.14.0 上运行过,使用了 requests 2.33.0 和 capskip 1.3.0。
- 一个 PowerShell 提示符。这些命令使用 PowerShell 语法。
- 你拥有或被允许自动化操作的页面。
- 识别工具的地址。在 Local 模式下,CapSkip 只在 127.0.0.1 上响应,仅供本机使用。在 Server 模式下,它监听你的网络地址或公网 IP,这样另一台 PC、VPS 或托管平台上的爬虫就能通过 API 访问它。两种模式的设置位置都是 连接设置.
测试方式:分类器和抓取循环在 Python 3.14.0 上通过了 17 项检查,每一项都是由本地测试站点提供的一个响应。识别通过真实的 capskip 包,发往一个模拟 CapSkip,而不是应用本身。我们还在 2026 年 10 月 29 日抓取了两个位于 Cloudflare 整页检查后面的公开页面,两者都返回了 403,并带有 cf-mitigated 响应头。下面的任何数字都不是识别耗时。
为什么首先要问:验证码还是封锁
这两种错误的代价不同。把封锁页面送给识别工具,会白费这次识别,而你仍然被封锁。每次出现验证码就换一个新代理,会消耗掉你本不必消耗的地址。提供验证码的页面通常是在要求你证明点什么,而识别得到的 token 能让你通过。提供封锁的页面则是直接拒绝你。 HTTP 429 指南 讲了针对一个状态码的等待这一面。本指南讲的是在它之前要做的判断。
六个标签涵盖了所有情况:ok、rate_limited、captcha、cloudflare_challenge、blocked 和 unknown。unknown 标签很重要。必须在其余五个里做选择的分类器,有时会选错,而遇到 unknown 就抛出异常的循环,会让你看到自己还没有处理的情形。
第 1 步:安装
在一个空文件夹中创建虚拟环境,并安装这两个包。这些命令通过路径调用该环境自己的 Python,所以不需要激活它。
py -m venv .venv .\.venv\Scripts\python.exe -m pip install capskip requests
第 2 步:信号
把这个代码块和下一个代码块保存为 classify.py。第一个代码块保存各种模式。小组件模式查找类属性里写着某个小组件名称的标签,或者 altcha-widget 标签。在转义代码里展示标记的博客文章、地址中含有小组件名称的链接,以及隐藏的 g-recaptcha-response input,这些都不会匹配。
最后一行保存用来识别软封锁的短语,所谓软封锁,就是返回正常状态码、但页面写着你被拒绝的页面。使用时要小心。Cloudflare 的错误文档列出了 Error 1020: Access denied,前两个短语就来自这里。另外两个是根据实际见到的封锁页面猜出来的,而你的目标站点会有它自己的措辞。看过几个真实的之后,再替换它们。
import re
# A tag whose class attribute names a widget, or an altcha-widget tag. A link, a hidden
# input or a sentence that merely contains the name does not match.
CLS = r"<[^>]*\bclass\s*=\s*[\"'][^\"']*(?<![\w-])%s(?![\w-])"
WIDGET = re.compile(CLS % "(?:g-recaptcha|h-captcha|cf-turnstile|frc-captcha|captchafox)"
+ r"|<altcha-widget\b", re.I)
TITLE = re.compile(r"<title[^>]*>(.*?)</title>", re.I | re.S)
BLOCK = ("access denied", "error 1020", "unusual traffic", "you have been blocked")第 3 步:判断是验证码还是封锁
检查的顺序就是逻辑。每条规则只有在它上面的规则都没有匹配时才会运行。
- cf-mitigated 响应头排在最前面。Cloudflare 的文档说,它在挑战页面上发送的值是 challenge,这也是这个函数里最强的信号。
- 接下来检查重定向,针对关闭了跟随重定向的调用方。它被判为 unknown,因为只凭地址无法判断它通向验证码,还是通向名字里带这个词的一篇文章。
- 然后是 _cf_chl_opt 脚本变量和 Just a moment 标题。当代理或 CDN 去掉了响应头时,它们能识别出挑战页面。
- expect 参数让你指定一个只有真实页面才包含的字符串。有了它,无论状态码是什么,页面都会被判为 ok。没有它的话,页脚里带验证码小组件的内容页面会被判为验证码。
- 标记里出现小组件就意味着 captcha,即使状态码是 429 或 403。这一步在状态码规则之前运行,因为有些站点是用错误状态码来提供验证码的。小组件列表涵盖 reCAPTCHA、hCaptcha、Turnstile、Friendly Captcha、CaptchaFox 和 ALTCHA。
- 429,或者带 Retry-After 的 503,表示 rate_limited。带 HTML 正文的 403 表示 blocked,除非页面文本包含 csrf。JSON 形式的 403 保持为 unknown,因为一个说你的 token 缺少某个权限范围的 API,并不是在叫你轮换。
- 状态码低于 400 的响应,在标题包含其中一个短语时,或者整个页面短于 3,000 个字符且包含其中一个短语时,会被判为软封锁。
def classify(resp, expect=None):
"""expect: a string only the real page contains, for pages that carry a widget anyway."""
code, h = resp.status_code, resp.headers
if h.get("cf-mitigated", "").lower() == "challenge":
return "cloudflare_challenge" # Cloudflare docs
if 300 <= code < 400: # allow_redirects=False callers
return "unknown"
html = "html" in h.get("content-type", "").lower()
text = resp.text[:200000] if html else ""
low = text.lower()
if "_cf_chl_opt" in text or "<title>just a moment" in low:
return "cloudflare_challenge" # header stripped by a proxy
if expect and expect in text:
return "ok"
if WIDGET.search(text):
return "captcha" # even on a 429 or a 403
if code == 429 or (code == 503 and "retry-after" in h):
return "rate_limited"
if code == 403 and html and "csrf" not in low:
return "blocked" # JSON 403s fall through
m = TITLE.search(text)
head = m.group(1).lower() if m else ""
small = len(text) < 3000 # block pages are short
if code < 400 and html and any(b in head or (small and b in low) for b in BLOCK):
return "blocked" # soft block: 200 with a notice
return "ok" if code < 400 else "unknown"第 4 步:读取 Retry-After
把这个代码块、下一个和再下一个保存为 policy.py。429 可能带有 Retry-After 响应头,其值可以是秒数,也可以是 HTTP 日期。MDN 列出了这两种形式,并说明在 429 上这个响应头是可选的。辅助函数两种都能处理,并在响应头缺失或无法读取时,回退到你选定的数值。
import re
import time
from datetime import datetime, timezone
from email.utils import parsedate_to_datetime
from urllib.parse import urljoin
from classify import classify
def retry_delay(resp, fallback):
raw = resp.headers.get("Retry-After")
if not raw:
return fallback
try:
return max(1.0, float(raw)) # delay-seconds
except ValueError:
pass
try:
when = parsedate_to_datetime(raw) # HTTP-date
return max(1.0, (when - datetime.now(timezone.utc)).total_seconds())
except (TypeError, ValueError):
return fallback第 5 步:识别验证码
这是唯一调用 CapSkip 的地方。它从页面读取 sitekey 和表单 action,向 CapSkip 请求一个 token,并把它放在 g-recaptcha-response 字段里提交回去。它有意只处理 reCAPTCHA v2,而且只提交这一个字段,别的都不提交,所以带有其他必填字段的表单,需要你把它们加到数据里。列表里的每个小组件都得到 captcha 标签,因此 Turnstile、hCaptcha、Friendly Captcha、CaptchaFox 或 ALTCHA 页面也会到达这个函数。它会用一个 RuntimeError 拦下这些页面,消息里提示应先使用 验证码类型检测器 检查一遍。把 Turnstile 的 key 改送给 reCAPTCHA 方法,结果会是 ERROR_GOOGLEKEY:CapSkip 会拒绝看起来不像 reCAPTCHA key 的 key,这一点是我在一个照搬了应用 key 检查逻辑的模拟程序上确认的,不是在应用本身上。关于 reCAPTCHA 调用本身,请参阅 reCAPTCHA v2 识别工具页面.
def solve_recaptcha_form(session, resp, solver):
"""reCAPTCHA v2 only. For other types, run the detector first."""
key = re.search(r"data-sitekey=[\"']([^\"']+)", resp.text)
form = re.search(r"<form[^>]*action=[\"']([^\"']*)", resp.text)
if "g-recaptcha" not in resp.text or not (key and form):
raise RuntimeError("not a plain reCAPTCHA v2 form: run the detector first")
token = solver.recaptcha(sitekey=key.group(1), url=resp.url)["code"]
return session.post(urljoin(resp.url, form.group(1)),
data={"g-recaptcha-response": token}, timeout=30)第 6 步:抓取循环
这个循环根据“验证码还是封锁”的判断来行动。它把每个标签变成一个动作,最多尝试四次。速率限制会按响应头的值休眠,下限 1 秒,上限 60 秒。上限意味着,一天的 Retry-After 会在一分钟后重试,这可能延长限制。不带 Retry-After 的 429 会等待两次、每次 5 秒,然后轮换,而且对新地址重新开始计数。四次尝试的话,就是等待两次、轮换一次、再等待一次。带有 Retry-After 的 429 永远不会轮换。封锁会调用你的 rotate 函数,永远不会到达识别工具。你的 rotate 函数应该改变下一次请求的样子,例如设置 session.proxies,或者抛出异常来停止运行。挑战页面会调用你的 on_challenge 函数,它应该在 session.cookies 上留下一个有效的 cf_clearance cookie,因为 Turnstile 方法在整页挑战中所需要的值,不在普通请求收到的 HTML 里。任一函数返回后,循环只是再次请求该 URL。成功时,函数返回表单提交的响应,所以如果你的目标页面是另一个,请再请求一次。每次验证码尝试消耗一次识别,最多可能等待五分钟,所以四次失败的尝试可能用掉四次识别。NetworkException 表示 CapSkip 无法访问,这里不会捕获它。 cf_clearance 指南 介绍了随后出现的那个 cookie。
def fetch(session, url, solver, rotate, on_challenge,
tries=4, sleep=time.sleep, expect=None):
limited = 0
for _ in range(tries):
resp = session.get(url, timeout=30)
kind = classify(resp, expect)
if kind == "ok":
return resp
if kind == "rate_limited":
limited += 1
if limited > 2 and "Retry-After" not in resp.headers:
rotate(session) # a bare 429 that outlasts two waits
limited = 0 # the new address starts fresh
else:
sleep(min(retry_delay(resp, 5.0), 60.0))
elif kind == "captcha":
resp = solve_recaptcha_form(session, resp, solver)
if classify(resp, expect) == "ok":
return resp
elif kind == "cloudflare_challenge":
on_challenge(session, resp) # yours: a browser, or the Turnstile guide
elif kind == "blocked":
rotate(session) # never solve a block
else:
raise RuntimeError(f"unknown response {resp.status_code} from {url}")
raise RuntimeError(f"gave up on {url} after {tries} tries")运行它
把它保存为 use.py,放在另外两个文件旁边。替换其中的地址,并把你自己的代理逻辑放进 rotate。示例中的 rotate 会抛出异常,这样被封锁的运行会停止,而不是向同一个地址请求四次。用该环境自己的 Python 运行,如 .\.venv\Scripts\python.exe use.py。端口 8080 是默认值;如果你的不同,请使用 CapSkip 连接设置中显示的端口。使用占位地址时,脚本会以未知响应错误结束,这是预期结果。
import requests
from capskip import CapSkip
from policy import fetch
solver = CapSkip(host="127.0.0.1", port=8080)
def rotate(session):
raise RuntimeError("blocked: change the proxy here, or stop the run")
def on_challenge(session, resp):
raise RuntimeError("Cloudflare challenge page: needs a browser")
session = requests.Session()
page = fetch(session, "https://example.com/data", solver, rotate, on_challenge)
print(page.status_code, len(page.text))在信任分类器之前,想看看它如何处理真实的响应,可以对你自己的几个 URL 运行这个检查。在 2026 年 10 月 29 日的一次运行中,它对普通页面打印了 ok,对 429 打印了 rate_limited,对来自测试服务的 403 打印了 blocked。
import requests
from classify import classify
URLS = [
"https://example.com/",
"https://httpbin.org/status/429",
"https://httpbin.org/status/403",
]
for url in URLS:
resp = requests.get(url, timeout=30)
print(classify(resp), resp.status_code, url)分类器出错的地方
它是一组启发式规则,已知有这些情况。
| 情况 | 结果 | 修复 |
|---|---|---|
| 页脚里带验证码小组件的内容页面 | captcha | 把 expect 传给 classify 或 fetch,值是只有真实页面才有的字符串 |
| 标题里带有 unusual traffic 的博客文章 | blocked | 把 BLOCK 里的短语替换成你目标站点自己的 |
| 应用自身返回的 HTML 403,例如登录页面 | blocked | 在 403 规则之前为该页面添加一条规则 |
| 直接访问(或重定向到)只有一张图片的 /captcha 页面 | ok | 为该站点添加一条规则 |
| 带有 g-recaptcha 类和 data-action 属性的按钮(reCAPTCHA v3 绑定) | captcha | 该小组件从不拦住页面:传入 expect,或者运行检测器 |
| 加载后由 JavaScript 添加的验证码 | ok | 分类器只能看到第一个响应 |
| 全站的 reCAPTCHA v3 脚本 | ok | 这是有意为之:v3 从不拦住页面 |
常见错误
| 错误 | 原因 | 修复 |
|---|---|---|
| RuntimeError: unknown response | 规则没有涵盖的状态码,例如 JSON 形式的 403 或 404 | 打印正文并添加一条规则,或者让它停止运行 |
| RuntimeError: gave up | 尝试四次都没有得到 ok | 检查重复出现的是哪个标签,只有当它是 rate_limited 时才增加 tries |
| RuntimeError: not a plain reCAPTCHA v2 form | captcha 标签来自另一个小组件,或者页面没有 sitekey 或表单 | 运行检测器,并调用匹配的方法 |
| ERROR_GOOGLEKEY | key 不像 reCAPTCHA key,例如 Turnstile key 或单词 explicit | 从小组件本身读取 key,或者运行检测器 |
| ApiException 或 TimeoutException | 识别失败,或者超出了 SDK 的限制 | 检查在它之前的标签,并把 tries 保持在较低水平,这样失败的验证码就不会用掉很多次识别 |
| NetworkException | CapSkip 没有在运行,或者主机或端口不对 | 启动 CapSkip 并检查连接设置,请参阅 API 文档 |
把识别工具跑在服务器上
这个循环只需要 CapSkip 的主机和端口。在 Local 模式下,那就是同一台 PC 上的 127.0.0.1。在 Server 模式下,CapSkip 监听你的网络地址或公网 IP,所以另一台 PC、VPS 或托管平台上的爬虫可以调用同样的 API。在 CapSkip 构造函数中更改主机和端口,其他都不用变。建议使用静态公网 IP,以获得稳定的远程访问。Server 模式仍然是你自己的硬件,仍然不计量。它会对任何地址开放这个端口,所以请为不应访问它的地址添加 Windows Firewall 阻止规则。 连接设置 说明了如何切换模式。
常见问题
我怎么知道自己是被封锁了,还是只是被限速了?
429,尤其是带有 Retry-After 响应头的,是速率限制,等待就有效。带 HTML 正文的 403,或者写着拒绝访问的 200 页面,是封锁。等了两次仍然持续的不带 Retry-After 的 429,按经验法则被当作封锁,这就是循环对它进行轮换的原因。
识别出来的验证码能解决封锁吗?
不能。封锁拒绝的是你的地址或你的指纹,而 token 对这两者什么也证明不了。遇到封锁时,循环会跳过识别工具,转而轮换。
为什么循环不识别 Cloudflare 挑战页面?
Turnstile 方法需要挑战页面中的两个值,而普通请求拿不到它们。循环把这种情况交给你提供的函数,比如驱动真实浏览器的函数,并让分类器如实反映它能看到什么。
这能对着另一台机器上的 CapSkip 运行吗?
可以。使用 Server 模式,让 CapSkip 构造函数指向那个主机和端口,循环的工作方式就和在一台 PC 上一样。识别工具仍然在你自己的硬件上。
简短版结论
先判断是验证码还是封锁,再行动:识别验证码,等过速率限制,从封锁处轮换离开,遇到 unknown 就停下。CapSkip 运行在你自己的机器上,所以跳过一次注定失败的识别,省下的是时间,避免不必要的轮换,省下的是地址。如果想更全面地了解如何通过验证码页面,可以采用 验证码绕过 的思路:能识别的就识别,其余的绕开。关于 Python SDK 本身,请参阅 Python 验证码识别页面.
