凌晨 02:14,一条实时价格数据流不再产出数据行。没有任何人收到告警。传输层没有报告故障,请求照常完成,受影响的数据源返回的是 200 OK。按 HTTP 客户端能理解的每一个信号,流水线都是健康的。按业务唯一在乎的那个信号,数据流已经断了。
问题出在响应体里。数据源开始返回 Cloudflare Turnstile 中间页而不是价格,客户端把这个 200 当成了成功,于是验证页一路流到下游,被当作数据解析。传输层有成功信号,内容层却什么信号都没有。
这篇文章按事后复盘的写法来写。事件是一个有代表性的场景,依据这类故障通常的表现方式重建,而修复方案刻意没有做成 Turnstile 求解器。它在响应边界加入 验证检测,把每个响应归为 ok、challenge或 hard_block,并把遇到验证的请求交给 Crawlbase JavaScript token,由它渲染页面并在上游处理验证。流水线的职责缩小为发现情况、选择传输方式、核验返回内容。
- HTTP 状态描述的是传输,不是内容。一张验证页完全可以是合法的
200。 - 基于响应体和响应头做检测,并写成 纯函数,这样故障时的那份响应就能永远作为回归测试反复重放。
- 分成三种结果,而不是两种。
challenge和hard_block都意味着“没有可用内容”,却需要截然相反的处理。 - 要 先 检查验证,再去相信
200。把顺序颠倒,就等于重建了原来的事件。 - 对改道后的响应要再检查一次。换了传输方式不代表验证已经处理;
cb_status加上一次干净的检测结果才算。
一份有代表性的时间线
- T+0(02:14)。 价格数据行不再到达,仪表盘变成一条平线。
-
T+9m。 值班人员开始排查。日志显示受影响数据源返回
200 OK,传输看起来没问题,注意力被引向了别处。 -
T+18m。 有人抓取了一份原始响应体。文档标题是
Just a moment...,里面带着 Turnstile 组件。 -
T+24m。 根因:摄取链路用 HTTP 状态定义成功。在这种故障形态下,验证页以
200返回,不过403同样常见。 - T+41m。 预发环境上的修复检测到验证,并把受影响的 URL 改道到 JavaScript token。
- T+58m。 数据行恢复。抓到的响应体被保留为测试夹具,这样不必等数据源再次下发验证也能测试检测逻辑。
Cloudflare 上了验证并不是结论;数据源随时都在调整防护。真正的结论是,流水线分不清成功的 HTTP 响应和成功的内容,于是把四种运维上完全不同的情况压成了一个布尔值。
200 就是这次事件:它正是状态检查会直接放行的那一格。修复的结构
四个部分,每个都刻意保持很小:
- 一个 检测器:对
{ status, headers, html }的纯函数,报告出现了哪些验证信号。不发起网络请求,正是这一点让保存下来的响应可以重放。 - 一个 分类器,把这些信号转成
ok、challenge或hard_block。一个笼统的“失败”状态无法告诉流水线下一步该做什么。 - 两个 传输,返回形状相同:
directFetch是出故障的那条路径,crawlbaseFetch是修复路径。 - 一份 事件日志,把检测、分类和修复记录成可以直接贴进复盘的时间线。
检测器的纯粹性是回报最持久的部分。一旦检测是基于保存输入的函数,故障时的那些字节就变成每次改动都会运行的测试,“修好了吗”也不再取决于数据源此刻是否恰好在下发验证。
可运行的代码在 ScraperHub/solving-cloudflare-turnstile-a-technical-postmortem中,完整版本位于 final/,分阶段的检查点位于 steps/。下面的代码片段摘自 final/。
运行环境
Node.js 18 或更新版本,用它内置的 fetch,再加一个 Crawlbase 账号。修复路径使用 JavaScript token。这遵循我们为 Crawling API制定的升级规则:Normal token 请求如果返回空响应或 525(表示验证未能通过),应改用 JavaScript token 重试,而 Turnstile 中间页正是这条规则要应对的情形。检测和分类两步完全不需要 token。
git clone https://github.com/ScraperHub/solving-cloudflare-turnstile-a-technical-postmortem.git cd solving-cloudflare-turnstile-a-technical-postmortem/final npm install cp .env.example .env # then set CRAWLBASE_JS_TOKEN
第 1 步:配置
由一个模块读取环境变量。token 在加载时是可选的,只在修复路径上才必需,因此在任何人拿到凭据之前,就能复现并分析事件。
const config = { crawlbaseJsToken: process.env.CRAWLBASE_JS_TOKEN || '', controlUrl: process.env.CONTROL_URL || 'https://example.com', targetUrl: process.env.TARGET_URL || 'https://crawlbase.com/blog', requestTimeoutMs: Number(process.env.REQUEST_TIMEOUT_MS || 20000), };
对照 URL 是默默无闻的主角。它是一个永远不该触发检测器的页面;一旦触发,问题就出在你的检测逻辑,而不是目标站点。没有它,一个把什么都标记的检测器,和一个处处下发验证的数据源,看起来毫无区别。
第 2 步:可重放的检测器
原来的逻辑把状态当成内容的证明。检测器通过查看响应体和响应头替换了这个假设,并返回它发现了什么,而不是给出结论。
const HTML_MARKERS = [ 'challenges.cloudflare.com/turnstile', 'cf-turnstile', '__cf_chl_', 'cf_chl_opt', 'window._cf_chl_opt', 'Just a moment', 'Checking your browser', ]; const CHALLENGE_HEADERS = ['cf-mitigated']; function detect({ status, headers = {}, html = '' }) { const lowerHeaders = {}; for (const [key, value] of Object.entries(headers)) { lowerHeaders[key.toLowerCase()] = String(value).toLowerCase(); } const hitMarkers = HTML_MARKERS.filter((marker) => html.toLowerCase().includes(marker.toLowerCase()) ); const cfMitigated = CHALLENGE_HEADERS.some((h) => lowerHeaders[h]) && (lowerHeaders['cf-mitigated'] || '').includes('challenge'); const servedByCloudflare = (lowerHeaders['server'] || '').includes('cloudflare') || Boolean(lowerHeaders['cf-ray']); const hasTurnstileWidget = hitMarkers.some( (m) => m === 'cf-turnstile' || m === 'challenges.cloudflare.com/turnstile' ); return { status, servedByCloudflare, cfMitigated, hasTurnstileWidget, challengeMarkers: hitMarkers, challengeDetected: cfMitigated || hitMarkers.length > 0, }; }
这些标记是 Cloudflare 验证实际携带的字符串:Turnstile 脚本地址、cf-turnstile 容器、__cf_chl_ 和 cf_chl_opt 这两个验证命名空间、中间页标题,以及 cf-mitigated: challenge 响应头。检测器只检查它们是否存在,别的什么都不做,也从不触碰组件。
把它跑在故障期间抓到的响应体上:
npm run detect -- fixtures/turnstile-challenge.html
它应当报告 outcome: "challenge",并列出匹配到的标记。这份夹具是仓库里最有价值的文件:验证页会变,而保存下来的响应让你对检测器的任何改动都能证明,它没有悄悄认不出当初拖垮数据流的那个东西。
这里要说明子串匹配的一个真实局限。'Just a moment' 和 'cf-turnstile' 是纯文本,所以任何只是 提到 它们的页面也会匹配。把 TARGET_URL 指向这篇文章,检测器就会把它判为验证,因为文章引用了列表里的每一个标记。这正是对照 URL 和已知正常夹具要捕捉的那类误报,在生产环境中,这也说明应当先要求出现结构性信号,比如响应头或组件脚本,再去相信纯文本匹配。
第 3 步:三种结果,而不是两种
检测器说明出现了什么。分类器把它变成流水线可以据以行动的东西。
const OUTCOME = { OK: 'ok', CHALLENGE: 'challenge', HARD_BLOCK: 'hard_block', }; function classify(signals) { if (signals.challengeDetected) { return OUTCOME.CHALLENGE; } if (signals.status === 200) { return OUTCOME.OK; } if ([403, 429, 503].includes(signals.status) && signals.servedByCloudflare) { return OUTCOME.HARD_BLOCK; } return signals.status === 200 ? OUTCOME.OK : OUTCOME.HARD_BLOCK; }
顺序就是整个设计。验证检查排在最前,因为中间页既可能以 200 返回,也可能以 403返回。让针对 200 的检查先生效,分类器就会按构造重现这次事件。
结尾也要仔细看。前两个分支返回之后,后两个分支只可能得到 hard_block:Cloudflare 专用分支和兜底分支结论一致。因此,任何既不是验证、也不是 200 的响应都算硬封锁,包括 404、500,以及超时后以状态 0返回的请求。对演示来说这是保守的默认值,也是生产环境里第一个该拆开的地方,因为临时的 503 值得重试,而硬封锁值得退避。
-
ok:没有验证信号且状态为200。交给常规内容校验。 -
challenge:可检测到的 Cloudflare 验证。通过 JavaScript token 改道。 -
hard_block:没有可用内容,也没有可移交的验证。对该主机退避或更换策略;用同样方式重试同一请求无济于事。
第 4 步:通过 Crawlbase 修复
修复路径是第二个传输,返回形状与 directFetch相同,因此检测、分类和日志从来不需要知道响应来自哪条路径。此片段省略了错误处理。
async function crawlbaseFetch(url, token) { const started = Date.now(); const endpoint = `https://api.crawlbase.com/?token=${token}&url=${encodeURIComponent(url)}`; const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), config.requestTimeoutMs); try { const response = await fetch(endpoint, { signal: controller.signal }); const html = await response.text(); return { transport: 'crawlbase-js', status: Number(response.headers.get('cb_status') || response.status), originalStatus: Number(response.headers.get('original_status') || 0), headers: normalizeHeaders(response.headers), html, latencyMs: Date.now() - started, }; } finally { clearTimeout(timer); } }
判定由两个响应头承载。cb_status 是 Crawlbase 对这次请求的结果;original_status 是目标站点的回答。请以 cb_status作为分支依据,并把 525 当成单独的情况:它表示验证未能通过,应当先重试再退避,而不是存储。
改道后的响应在存储前会再次经过同一个检测器和分类器。换了传输方式不代表验证已被处理。只有当 cb_status 报告成功 并且 响应体中没有验证信号时,内容才会被接受。
配套仓库是这样把两个传输连起来的:
if (directResult.outcome !== OUTCOME.OK) { if (!config.crawlbaseJsToken) { incident.warn('CRAWLBASE_JS_TOKEN not set; cannot run the remediation path.'); } else { incident.fix('Routing target through Crawlbase JavaScript token.'); const viaCrawlbase = await crawlbaseFetch(config.targetUrl, config.crawlbaseJsToken); const crawlbaseResult = inspect(viaCrawlbase); incident.log( crawlbaseResult.outcome === OUTCOME.OK ? 'fix' : 'warn', `target via crawlbase-js -> ${crawlbaseResult.outcome} ` + `(cb_status ${viaCrawlbase.status}, ${viaCrawlbase.latencyMs}ms)` ); } }
注意这个条件:!== OUTCOME.OK。演示会改道所有不干净的响应,连硬封锁也一起,这是恢复数据流最简单的办法,也是最该先改的一行。硬封锁没有可移交的验证,所以把它送进修复路径,只会在一个很可能以同样方式失败的请求上花钱、增加延迟。一旦有了这几种结果,就给每种结果各自的出口:
// Production shape, not from the repository: one exit per outcome. switch (directResult.outcome) { case OUTCOME.OK: return store(direct); case OUTCOME.CHALLENGE: return rerouteAndVerify(url); case OUTCOME.HARD_BLOCK: return backOff(new URL(url).host); }
403,检查结果为 challenge,同一个 URL 通过 JavaScript token 再次发出。渲染后的页面带着 cb_status 200 返回,并在存储前再检查一次。运行
npm start
工具会重放保存的夹具、检查对照 URL,然后直接请求目标,并把每一步记录进事件时间线:
# Turnstile challenge on realtime ingestion T+0.0s [WARN] Replaying saved fixtures from the outage window. T+0.0s [WARN] fixture turnstile-challenge.html -> challenge (markers: ...) T+0.0s [OK] fixture ok-page.html -> ok (no false positive expected) T+0.1s [OK] control https://example.com -> ok (http 200, 133ms) T+1.0s [OK] target https://crawlbase.com/blog via direct -> ok (http 200, 843ms)
注意这次运行证明了什么、没证明什么。实时目标返回了干净的内容,所以没有发生改道,也没有花掉任何 Crawlbase 请求。验证路径是靠重放夹具覆盖到的,这正是保留夹具的意义:检测可以确定性地验证,不需要数据源恰好在你运行工具的那一刻下发验证。当目标真的下发验证时,直接请求的结果会被归为 challenge,URL 通过 JavaScript token 发出,返回的响应体在计入之前会再检查一次。
在请求内部完成真实浏览器渲染和反爬验证处理,并用 cb_status 告诉你是否成功。失败的请求不计费,所以没能处理的验证只花费延迟,而不花钱。免费开始,含最多 5,000 次请求,无需绑卡。
生产环境注意事项
用结果把控写入,而不是用状态。 这是唯一一个能阻止这次事件的控制点。200 只是允许你检查,不是允许你存储。
保留夹具,并持续补充。 验证页的标记会变。每抓到一种新变体,就多一个回归用例,而已知正常的夹具则守住另一半,证明检测器不会把正常页面误判。
把硬封锁和临时错误分开。 演示的兜底逻辑把超时、5xx 响应和真正的封锁合并成一种结果。给临时故障有限次数的重试,把退避留给真正的封锁,否则上游一次短暂抖动就会让一个健康的主机被暂停。
不要改道你修不好的请求。 只有 challenge 才走 JavaScript token。把硬封锁也改道,只会推高改道量和成本,却拿不回内容。
用够用的最便宜的 token。 大部分流量应走直连路径或 Normal token。按响应逐个升级,而不是默认全部走 JavaScript token,能让延迟和花费与真正遇到验证的流量占比保持成比例。
把改道率当作信号来监控。 某个主机的 challenge 结果突然上升,就是原来那条流水线从未有过的早期预警。对它设置告警,你就能在仪表盘变平之前知道下一次防护变化。
明确访问范围。 只请求你有权访问的数据源,并遵守它们的条款和频率要求。示例用 example.com 作为对照、用 Crawlbase 博客作为目标,正是出于这个原因。
想从整体上了解这些防护是如何运作的,现代反爬规避的系统视角 讲的是系统层面,而 如何避开 Cloudflare 机器人检测 讲的是单个请求层面。
结语
这次故障其实从来不是 Cloudflare 的问题,而是一条让 HTTP 状态冒充内容的流水线的问题:数据源一旦用验证页作答,故障就变得不可见。
修复先让它可见,再把它变成一个决策。一个能对故障时原始字节反复重放的纯检测器。一个有三种结果、先检查验证再相信 200的分类器。一个把遇到验证的请求交给 JavaScript token 的第二传输,以及一次在响应体干净之前拒绝存储任何内容的二次检查。应用本身从不求解任何东西;它只负责发现、路由和核验。
要复现这套做法,注册一个免费的 Crawlbase 账号,然后克隆 ScraperHub/solving-cloudflare-turnstile-a-technical-postmortem。
常见问题(FAQ)
这算绕过 Cloudflare Turnstile 吗?
应用本身并不绕过。它检测到验证后,把受影响的请求交给使用 JavaScript token 的 Crawling API,由后者在托管请求中渲染页面并在上游处理验证。流水线里没有任何验证求解逻辑,访问规则也和任何请求一样:只访问你有权访问的数据源。
为什么客户端对验证页拿到的是 200 OK?
因为中间页本身就是一个合法的 HTTP 响应。Cloudflare 可以用 200 或 403返回验证页。用状态码定义成功的流水线会把验证页当作数据存下来,这正是本文发生的情况。
该用 Normal token 还是 JavaScript token?
从够用的最便宜 token 开始,依据证据再升级。我们文档中的规则是:Normal token 请求如果返回空响应体或 525,应改用 JavaScript token 重试。Turnstile 中间页正是这种升级的典型场景,所以本文的修复路径直接使用 JavaScript token。
怎么确认改道真的奏效了?
两个条件,缺一不可:cb_status 报告成功,并且返回的响应体通过检测器、不含验证信号。525 表示验证未能通过;先重试,如果持续出现就退避并排查,因为这通常意味着目标站点上线了新的验证变体。
为什么要把 hard_block 和 challenge 分开?
因为它们需要相反的处理。验证有东西可以移交,所以改道能拿回内容。硬封锁没有,所以改道只是以更高成本重复一次失败。把两者塞进同一个“失败”状态,正是流水线要么无休止重试封锁、要么永远无法从验证中恢复的原因。
大规模爬取任何站点,无需与基础设施对抗。
Crawlbase 负责处理代理、指纹和 CAPTCHA,让你的团队专注于交付数据流水线,而非维护爬取管道。1,000 次请求免费,无需信用卡。
