- 使用
cloudflyer驱动真实浏览器完成人机验证,取得cf_clearance和浏览器的 User-Agent。 - 使用
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不是completed或failed,继续等待即可。 - 请求临时失败。捕获了所有
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_PROXY、HTTPS_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-Ua与ver使用相同 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,而是完整替换三项状态:
- 通过 cloudflyer 获取新的
cf_clearance。 - 使用新返回的 User-Agent 重建
curl_cffi Session。 - 清空和旧会话绑定的表单令牌缓存。
必须重建整个 Session,是因为新的 cloudflyer 任务可能使用了不同 Chrome 版本或不同 User-Agent。只更新 Cookie 会留下请求头和网络指纹不一致的风险。
