
最近在排查一个爬虫项目,遇到了 Cloudflare 拦截问题,折腾了半天。改了 User-Agent、加了代理,本地跑通了,但 CI 环境又挂了。这种情况通常不是因为 Cloudflare 把"爬虫"当成了一个整体来拦截,而是它评估了多个信号,每个失败原因需要不同的处理方式。
本文讨论的是合规的自动化场景:自有站点、用户授权的工作流、测试、监控、以及你有权限访问的数据。如果目标站点没有授权你进行自动化操作,改 UA 或者轮换 IP 都不能让行为变得合规。
Cloudflare 的报错经常被笼统地归为"403",但返回的页面内容其实很关键。
常见情况:
在采取规避措施前,先进行错误分类。即使是最基础的分类逻辑也能节省大量时间:
import time
import requests CLOUDFLARE_MARKERS = { "1020": "cloudflare_access_denied", "Just a moment": "cloudflare_js_challenge", "cf-turnstile": "cloudflare_turnstile", "cf-error-code": "cloudflare_error_page",
} def classify_response(resp: requests.Response) -> str: body = resp.text[:5000] if resp.status_code == 429: return "rate_limited" for marker, label in CLOUDFLARE_MARKERS.items(): if marker in body: return label if resp.status_code == 403: return "forbidden_unknown" return "ok" if resp.ok else f"http_{resp.status_code}" def get_with_backoff(url: str, max_attempts=4): for attempt in range(max_attempts): resp = requests.get(url, timeout=20) reason = classify_response(resp) print(resp.status_code, reason) if reason == "ok": return resp if reason == "rate_limited": retry_after = resp.headers.get("Retry-After") sleep_for = int(retry_after) if retry_after else 2 ** attempt time.sleep(sleep_for) continue # 使用相同的客户端重试 1020 或 JS 挑战通常会重复相同的失败。 raise RuntimeError(f"Blocked by {reason}") raise RuntimeError("Exceeded retries")
关键在于不要对 1020 错误无限重试。如果 HTTP 客户端、IP 地址和请求时间都相同导致机器人检测决策,重试十次只会让你的信誉变得更差。
Cloudflare 可以在你的应用代码收到请求之前就开始评估流量。
从高层来看,你面对的是三个不同的检测层级:
requests、curl 或无头浏览器包装,而不是正常的浏览器。理解这一点很重要:代理只能改变网络出口,无法让 requests 执行 JavaScript。无头浏览器可以执行 JavaScript,但如果用同一账号并行跑 100 个会话,也无法解决速率限制问题。CAPTCHA 求解器可能完成挑战,但如果下一个页面导航再次被拦截,也没用。
如果你的爬虫已经变成了一堆带有拦截页面分类、重试状态和提取输出的浏览器任务队列,那用任务导向的架构比把每个页面都当作原始 HTTP 请求来处理更合适。
对于 IP 信誉相关的 403 错误,住宅代理或 ISP 代理可能有帮助,但这会增加成本、延迟和运维风险。当登录状态很重要时,你还需要保持会话粘性。频繁轮换 IP 会破坏 cookie、CSRF token 和欺诈检测系统——这些系统期望用户在整个会话期间保持在同一网络路径上。
如果响应体显示 Just a moment...、Turnstile 标记、或初始加载后出现 1020 页面,那普通的 HTTP 客户端可能就是错误的工具。对于真正需要浏览器的部分,使用 Playwright 或 Puppeteer。
将会话状态与凭证分开存储。登录一次,保存 cookies 和 local storage,然后在后续运行中复用这个浏览器状态:
// login.js
import { chromium } from "playwright"; const browser = await chromium.launch({ headless: false });
const context = await browser.newContext();
const page = await context.newPage(); await page.goto("https://example.com/login"); // 手动完成登录或使用你授权的测试凭证。
await page.waitForURL("**/dashboard", { timeout: 120000 }); await context.storageState({ path: "auth-state.json" });
await browser.close();
// scrape-authenticated.js
import { chromium } from "playwright"; const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({ storageState: "auth-state.json" });
const page = await context.newPage(); await page.goto("https://example.com/dashboard", { waitUntil: "domcontentloaded" });
await page.waitForLoadState("networkidle"); const title = await page.locator("h1").textContent();
console.log(title); await browser.close();
这种模式避免了在自动化数据库中存储密码。你存储的是会话产物,像对待密钥一样对待这个文件:加密它、按用户或租户隔离设置、以及主动设置过期时间。
浏览器自动化比 HTTP 爬取成本更高。它占用更多内存,执行时间是毫秒级而不是秒级,而且会以新的方式失败:字体缺失、不同的视口行为、过期的会话状态、慢速的第三方脚本。只在站点确实需要浏览器时才使用它,而不是把它作为每个 URL 的默认方案。
429 是唯一应该首先通过减慢请求来正确响应的情况。如果存在 Retry-After 响应头,请遵守它。如果不存在,使用带抖动的指数退避,并限制每个账号、每个 IP 和每个目标主机的并发数。
糟糕的扩容模式:
100 URLs -> 100 个并行浏览器会话 -> 同一账号 -> 同一来源 -> 429 或账号锁定
更好的模式:
预热会话 -> 顺序处理小批次 -> 逐步增加并发 -> 记录拦截率
分别统计这些指标:
这些数据会告诉你问题出在速率、浏览器挑战还是会话信誉。
下次调试时,在改变基础设施前先添加响应分类和按层级统计的指标。如果 429 占主导,就调并发。如果 1020 占主导,就检查浏览器执行。如果普通 403 占主导,就先看认证、地理位置和 IP 信誉。