2018 字
10 分钟
cloudflyer配合curl_cffi处理CloudflareChallenge人机验证
  1. 使用 cloudflyer 驱动真实浏览器完成人机验证,取得 cf_clearance 和浏览器的 User-Agent。
  2. 使用 curl_cffi 模拟相同 Chrome 大版本的网络指纹,带着配套 User-Agent 和 cf_clearance 请求目标站点。
使用范围

本文内容仅适用于自有系统、测试环境或已获得明确授权的自动化访问。cf_clearance 属于访问控制凭据,不应被记录到日志、提交到代码仓库或用于绕过未经授权站点的安全措施。

方案#

真实浏览器负责拿通行凭据,curl_cffi 负责尽量保持浏览器网络特征的一致性。

普通的 requests.Session() 即使携带同一个 cf_clearance,TLS ClientHello、密码套件顺序、扩展顺序和 HTTP/2 行为仍然与 Chrome 不同,Cloudflare 可能据此判定“完成验证的浏览器”和“正在使用 Cookie 的客户端”不是同一个访问者。

启动 cloudflyer 服务#

项目依赖中使用的是 cloudflyer==1.0.5。服务启动命令支持以下参数:

-K / --clientKey    客户端调用密钥,必填
-M / --maxTasks     最大并发任务数
-P / --port         监听端口
-H / --host         监听地址
-T / --timeout      单个任务最大超时
-L / --headless     使用无头浏览器

上面的key自己启服务的时候随便写就行,用的时候保持一致即可。

uv run cloudflyer -K your_key -H localhost -P 3001

服务启动后会创建浏览器窗口。后续的 Cloudflare 挑战任务会转交到该浏览器环境,挑战完成后由 cloudflyer 返回浏览器响应中的 Cookie 和请求头信息。

如果 cloudflyer 部署在另一台机器上,需要保证业务进程能够访问它。

创建 Cloudflare 挑战任务#

然后构造创建任务的 JSON:

{
  "clientKey": "zyk_test",
  "type": "CloudflareChallenge",
  "url": "target_url"
}

上述的url就是有Cloudflare人机验证的网站地址。

请求通过标准库 urllib.request 发送到:

POST http://localhost:3001/createTask
Content-Type: application/json

对应代码如下:

payload = json.dumps({
    "clientKey": client_key,
    "type": "CloudflareChallenge",
    "url": url,
}).encode()

req = urllib.request.Request(
    f"{server_url}/createTask",
    data=payload,
    headers={"Content-Type": "application/json"},
)
with urllib.request.urlopen(req) as resp:
    result = json.loads(resp.read())

urllib.request.Request 只要传入 data,请求方法就会默认为 POST。响应体被解析为 JSON,代码只关心顶层的 taskId

task_id = result.get("taskId")
if not task_id:
    raise RuntimeError(f"未获取到 taskId: {result}")

拿到 taskId 只代表任务已经进入 cloudflyer,并不代表挑战已经完成。浏览器启动、页面加载、人机检测和 Cookie 下发都需要时间,所以后面采用异步任务轮询。

查询挑战结果#

轮询请求发送到:

POST http://localhost:3001/getTaskResult
Content-Type: application/json

请求体只包含客户端密钥和任务 ID:

{
  "clientKey": "your_key",
  "taskId": "createTask 返回的任务 ID"
}

设置超时阈值。

任务状态分为三类。

  • 任务尚未完成。status 不是 completedfailed,继续等待即可。
  • 请求临时失败。捕获了所有 Exception,等待后继续查询结果,临时性问题。
  • 失败。明确返回 failed 时,任务已不可能成功,退出后重试。
  • 任务成功。

完成成功下,可以按以下层级读取数据:

task_result
└── result
    └── response
        ├── headers
        │   └── User-Agent
        └── cookies
            └── cf_clearance

逻辑示例:

result = task_result.get("result")
response_data = result.get("response")
headers = {
    "User-Agent": response_data["headers"]["User-Agent"],
}
cf_clearance = response_data["cookies"].get("cf_clearance")

代码会依次验证:

  • result 必须存在并且是字典。
  • response 必须存在。
  • cookies 中必须存在 cf_clearance

任一条件不满足都会抛出 RuntimeError,不会返回半成品结果。成功时返回:

这里不仅返回 Cookie,还必须返回浏览器的 User-Agent。原因是后续请求需要同时复用两者,避免出现“Cookie 来自 Chrome A,请求头却伪装成 Chrome B”的明显指纹冲突。

cf_clearance 还不够#

可以把 Cloudflare 对一次请求的观察信息简化成下面这个组合:

访问身份 ≈ cf_clearance
         + User-Agent
         + TLS ClientHello 指纹
         + HTTP/2 行为
         + 出口 IP
         + 其他浏览器与行为特征

这不表示每个站点都一定绑定全部字段,具体策略取决于 Cloudflare 配置和风险分。但从工程角度看,应尽量保持这些特征一致。

如果只把 cf_clearance 复制到普通 requests

  • User-Agent 可能和完成挑战的浏览器不同。
  • Python/OpenSSL 的 TLS 握手指纹与 Chrome 不同。
  • HTTP/2 设置、Header 顺序等网络行为也可能不同。
  • 如果获取 Cookie 和使用 Cookie 时走了不同代理,出口 IP 可能变化。

因此 cloudflyer 负责提供 Cookie 与 User-Agent,可以再使用 curl_cffi 模拟对应 Chrome 的 TLS/HTTP2 指纹。

构建 curl_cffi 浏览器会话#

1. 提取 Chrome 大版本#

从 cloudflyer 返回的 User-Agent 中提取 Chrome 主版本号:

2. 创建带 TLS 指纹的 Session#

session = cffi_requests.Session(
    impersonate=f"chrome{ver}",
    trust_env=False,
)

impersonate="chrome146" 不只是修改 User-Agent,而是让底层请求尽可能采用对应 Chrome 版本的 TLS 和 HTTP/2 特征。这正是它与普通 requests 的关键差异。

trust_env=False 表示不读取系统环境中的 HTTP_PROXYHTTPS_PROXY 等代理配置。这样请求路径不会因为运行机器的环境变量而悄悄变化。如果确实需要代理,代码只接受显式传入的地址:

if self._proxy:
    session.proxies = {
        "http": self._proxy,
        "https": self._proxy,
    }

3. 对齐浏览器请求头#

会话写入以下请求头:

session.headers.update({
    "User-Agent": user_agent,
    "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8",
    "Accept-Language": "en-US,en;q=0.9,zh-CN;q=0.8,zh;q=0.7",
    "Cache-Control": "max-age=0",
    "Sec-Ch-Ua": f'"Chromium";v="{ver}", "Google Chrome";v="{ver}"',
    "Sec-Ch-Ua-Mobile": "?0",
    "Sec-Ch-Ua-Platform": '"Windows"',
    "Upgrade-Insecure-Requests": "1",
})

这些头模拟桌面版 Chrome 的顶层文档导航:

  • Accept 表示客户端接受 HTML、XML 和现代图片格式。
  • Accept-Language 给出英文优先、中文次之的语言偏好。
  • Sec-Ch-Uaver 使用相同 Chrome 主版本。
  • Sec-Ch-Ua-Mobile: ?0 表示非移动端。
  • Sec-Ch-Ua-Platform: "Windows" 表示 Windows 平台。
  • Upgrade-Insecure-Requests: 1 是浏览器页面导航常见头。

最后把 Cookie 限定到目标根域:

session.cookies.set(
    "cf_clearance",
    self._cf_clearance,
    domain="taget_domain", # 目标网站的根域
)

识别 Cloudflare 挑战页#

HTTP 状态码并不足以识别挑战。Cloudflare 的验证页面可能返回 403,也可能返回 200 并在 HTML 中加载挑战脚本。因此代码对响应正文进行关键词检测:

markers = (
    "just a moment",
    "performing security verification",
    "verify you are human",
    "challenge-platform",
    "正在进安全验证",
    "验证您是真人",
)

检测前先把文本转成小写,只要任意标记是正文子串,就记录警告并返回 True

所有目标站 GET/POST 请求都经过统一包装:

def _get(self, url: str, **kwargs: Any) -> cffi_requests.Response:
    resp = self._session.get(url, timeout=30, **kwargs)
    if _is_cloudflare_response(resp.text):
        raise CloudflareChallengeError("Cloudflare 拦截")
    return resp
def _post(self, url: str, **kwargs: Any) -> cffi_requests.Response:
    resp = self._session.post(url, timeout=30, **kwargs)
    if _is_cloudflare_response(resp.text):
        raise CloudflareChallengeError("Cloudflare 拦截")
    return resp

这里使用专门的 CloudflareChallengeError,而不是普通 RuntimeError。上层可以只对 Cloudflare 拦截执行 Cookie 刷新,不会把页面解析错误、连接超时或服务器 500 都错误地当成验证失效。

两层运行期恢复逻辑#

对 Cloudflare 挑战采用两层处理。

第一层:原会话短暂重试一次#

某个关键 POST 请求检测到挑战页后,会先等待 3 秒,清空页面令牌缓存,再使用当前 Session 重新加载首页并重试:

try:
    resp = self._post(self._cache["state_url"], data=params)
except CloudflareChallengeError:
    logger.warning("CF on search, retrying ID %s...", cmmi_id)
    time.sleep(3)
    self._cache = None
    self._init_cache()
    params["__RequestVerificationToken"] = self._cache["ver_token"]
    resp = self._post(self._cache["state_url"], data=params)

这层重试不获取新 cf_clearance。它适用于短暂误判、页面状态刚好变化,或者原 Cookie 仍可继续使用的情况。

清空 _cache 很重要:会话刷新或挑战页出现后,之前页面中的防跨站请求令牌 __RequestVerificationToken 可能已经失效,不能只重发旧表单。重新加载首页后,代码同时获取新的表单地址和新令牌。

如果重新加载首页或第二次 POST 仍然检测到 Cloudflare,异常继续向上传递,进入第二层恢复。

第二层:重新获取 cf_clearance#

外层收到 CloudflareChallengeError 后调用:

def _refresh_cf_clearance(self) -> None:
    cf_headers, self._cf_clearance = solve_cf_challenge(self.base_url)
    self._session = self._build_session(cf_headers["User-Agent"])
    self._cache = None

刷新不是只替换 Cookie,而是完整替换三项状态:

  1. 通过 cloudflyer 获取新的 cf_clearance
  2. 使用新返回的 User-Agent 重建 curl_cffi Session
  3. 清空和旧会话绑定的表单令牌缓存。

必须重建整个 Session,是因为新的 cloudflyer 任务可能使用了不同 Chrome 版本或不同 User-Agent。只更新 Cookie 会留下请求头和网络指纹不一致的风险。

cloudflyer配合curl_cffi处理CloudflareChallenge人机验证
https://blog.kimbleex.top/posts/2026-08-06-cloudflare-challenge/
作者
许骏亮
发布于
2026-08-06
许可协议
CC BY-NC-SA 4.0