В 02:14 фид цен в реальном времени перестаёт выдавать строки. Никого не будят. Транспортный уровень не сообщает о сбое, запросы завершаются, а затронутый источник отвечает 200 OK. По всем сигналам, которые понимает HTTP-клиент, конвейер здоров. По единственному сигналу, который важен бизнесу, фид умолк.
Проблема в теле ответа. Источник начал отдавать вместо цен промежуточную страницу Cloudflare Turnstile, клиент принял 200 за успех, и страница проверки ушла дальше по конвейеру, где её разобрали так, будто это данные. Сигнал успеха на уровне транспорта был, а на уровне содержимого не было никакого.
Эта статья написана как постмортем, который пишут после такого случая. Инцидент показательный, восстановленный по тому, как обычно проявляется этот сбой, а исправление намеренно не является решателем Turnstile. Оно добавляет обнаружение проверки на границе ответа, относит каждый ответ к ok, challenge или hard_block и направляет запросы с проверкой через JavaScript token Crawlbase, который отрисовывает страницу и проходит проверку на стороне сервиса. Задача конвейера сводится к тому, чтобы заметить ситуацию, выбрать транспорт и проверить, что вернулось.
- 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. Исправление на staging обнаруживает проверку и направляет затронутый 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 это ровно тот случай, ради которого правило существует. Шагам обнаружения и классификации токен вообще не нужен.
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: конфигурация
Окружение читает один модуль. Токен необязателен при загрузке и нужен только на пути исправления, поэтому инцидент можно воспроизвести и разобрать до того, как кто-то раздобыл учётные данные.
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: URL скрипта Turnstile, контейнер cf-turnstile, пространства имён проверки __cf_chl_ и cf_chl_opt, заголовок промежуточной страницы и HTTP-заголовок 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 с виджетом Turnstile, разбор ответа даёт 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. Перенаправление жёстких блокировок раздувает объём и стоимость перенаправлений, не возвращая содержимого.
Берите самый дешёвый токен, который справляется. Большая часть трафика должна идти прямым путём или через Normal token. Эскалация по каждому ответу, а не перевод всего на JavaScript token по умолчанию, держит задержку и расходы пропорциональными доле трафика, который действительно получает проверки.
Следите за долей перенаправлений как за сигналом. Резкий рост исходов challenge для одного хоста это раннее предупреждение, которого у исходного конвейера никогда не было. Настройте на него оповещение, и вы узнаете о следующем изменении защиты раньше, чем дашборды выровняются в линию.
Держите границы явными. Запрашивайте только те источники, к которым у вас есть доступ, и соблюдайте их условия и ожидания по частоте. Пример использует example.com как контроль и блог Crawlbase как цель именно по этой причине.
Общую картину того, как работают эти защиты, даёт материал современный обход антибот-защиты изнутри, а про уровень отдельных запросов рассказывает обход обнаружения ботов Cloudflare.
Заключение
На самом деле сбой никогда не был про Cloudflare. Он был про конвейер, который позволял HTTP-статусу подменять содержимое, так что в момент, когда источник ответил страницей проверки, сбой стал невидимым.
Исправление делает его видимым, а затем превращает в решение. Чистый детектор, который можно прогнать на точных байтах из сбоя. Классификатор с тремя исходами, который ищет проверку, прежде чем доверять 200. Второй транспорт, который отправляет запросы с проверкой через JavaScript token, и повторный разбор, который отказывается что-либо сохранять, пока тело не чистое. Приложение ничего не решает; оно замечает, направляет и проверяет.
Чтобы воспроизвести, создайте бесплатную учётную запись Crawlbase и клонируйте ScraperHub/solving-cloudflare-turnstile-a-technical-postmortem.
Часто задаваемые вопросы
Это обходит Cloudflare Turnstile?
Приложение нет. Оно обнаруживает проверку и передаёт затронутый запрос в Crawling API с JavaScript token, который отрисовывает страницу и проходит проверку на стороне сервиса как часть управляемого запроса. Логики прохождения проверок в конвейере нет, и действуют те же правила доступа, что и для любого запроса: только источники, к которым у вас есть доступ.
Почему клиент получил 200 OK на проверку?
Потому что промежуточная страница сама по себе корректный HTTP-ответ. Cloudflare может отдать страницу проверки со статусом 200 или 403. Конвейер, который определяет успех по коду статуса, сохранит страницу проверки как данные, и именно это здесь и произошло.
Какой токен использовать, Normal token или JavaScript token?
Начинайте с самого дешёвого токена, который справляется, и эскалируйте по фактам. Наше задокументированное правило: запрос с Normal token, вернувший пустое тело или 525, нужно повторить с JavaScript token. Промежуточные страницы Turnstile это хрестоматийный случай такой эскалации, поэтому путь исправления в этой статье сразу идёт к JavaScript token.
Как понять, что перенаправление действительно сработало?
Два условия, оба обязательны: cb_status сообщает об успехе, и возвращённое тело проходит детектор без сигналов проверки. Код 525 означает, что проверку не удалось пройти; повторите, а если это повторяется, отступите и разберитесь, потому что обычно это значит, что цель выкатила новый вариант проверки.
Зачем разделять hard_block и challenge?
Потому что они требуют противоположных действий. У проверки есть что передать, поэтому перенаправление может вернуть содержимое. У жёсткой блокировки нет, поэтому перенаправление лишь повторяет отказ по более высокой цене. Сваливание их в одно состояние «ошибка» и приводит к тому, что конвейеры либо бесконечно повторяют блокировки, либо никогда не восстанавливаются после проверок.
Обходите любой сайт в масштабе, без борьбы с инфраструктурой.
Crawlbase берёт на себя прокси, отпечатки и CAPTCHA, чтобы ваша команда выпускала конвейеры данных вместо поддержки обвязки краулинга. 1 000 запросов бесплатно, без карты.
