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

captcha or block - How to Tell CAPTCHA or Block Apart in Python Scrapers

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 步:判断是验证码还是封锁

检查的顺序就是逻辑。每条规则只有在它上面的规则都没有匹配时才会运行。

  1. cf-mitigated 响应头排在最前面。Cloudflare 的文档说,它在挑战页面上发送的值是 challenge,这也是这个函数里最强的信号。
  2. 接下来检查重定向,针对关闭了跟随重定向的调用方。它被判为 unknown,因为只凭地址无法判断它通向验证码,还是通向名字里带这个词的一篇文章。
  3. 然后是 _cf_chl_opt 脚本变量和 Just a moment 标题。当代理或 CDN 去掉了响应头时,它们能识别出挑战页面。
  4. expect 参数让你指定一个只有真实页面才包含的字符串。有了它,无论状态码是什么,页面都会被判为 ok。没有它的话,页脚里带验证码小组件的内容页面会被判为验证码。
  5. 标记里出现小组件就意味着 captcha,即使状态码是 429 或 403。这一步在状态码规则之前运行,因为有些站点是用错误状态码来提供验证码的。小组件列表涵盖 reCAPTCHA、hCaptcha、Turnstile、Friendly Captcha、CaptchaFox 和 ALTCHA。
  6. 429,或者带 Retry-After 的 503,表示 rate_limited。带 HTML 正文的 403 表示 blocked,除非页面文本包含 csrf。JSON 形式的 403 保持为 unknown,因为一个说你的 token 缺少某个权限范围的 API,并不是在叫你轮换。
  7. 状态码低于 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 formcaptcha 标签来自另一个小组件,或者页面没有 sitekey 或表单运行检测器,并调用匹配的方法
ERROR_GOOGLEKEYkey 不像 reCAPTCHA key,例如 Turnstile key 或单词 explicit从小组件本身读取 key,或者运行检测器
ApiException 或 TimeoutException识别失败,或者超出了 SDK 的限制检查在它之前的标签,并把 tries 保持在较低水平,这样失败的验证码就不会用掉很多次识别
NetworkExceptionCapSkip 没有在运行,或者主机或端口不对启动 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 验证码识别页面.