Чтобы пройти проверку Cloudflare Turnstile из Node.js, нужны три вещи: строка sitekey со страницы (она начинается с 0x), токен от решателя и поле cf-turnstile-response в теле POST-запроса. Браузер для этого не обязателен — весь цикл укладывается во встроенный fetch, который есть в Node.js 18+ без внешних зависимостей.
Ниже разобрана вся последовательность: разбор HTML, вызов API CaptchaAI, отправка формы, а затем production-обвязка, параметры action/cData и таблица типовых ошибок. Ориентир по времени решения Turnstile — менее 10 с, поэтому опрос результата с шагом в 5 с обычно закрывается за две-три итерации.
Что понадобится
- Node.js 18+ — встроенный
fetch, без дополнительных пакетов - API-ключ CaptchaAI из панели управления
Тарификация у CaptchaAI идёт по потокам, а не по числу решений: один поток — одна задача в работе. Для одиночного скрипта хватает BASIC ($15/мес, 5 потоков); парсеру, который держит десятки параллельных сессий, ближе ADVANCE ($90/мес, 50 потоков). Цены фиксированные и в USD — предсказуемая месячная стоимость это заметный аргумент для команд из России, Беларуси и Казахстана, которые планируют бюджет в волатильной локальной валюте.
Шаг 1. Достаньте sitekey из HTML страницы
Turnstile отдаёт ключ сайта четырьмя разными способами, и единого места в разметке нет. Практичнее проверить их по очереди: атрибут data-sitekey на блоке cf-turnstile, тот же атрибут на любом другом элементе, вызов turnstile.render(...) в скрипте и, наконец, любое упоминание sitekey: внутри инлайн-JS. Ключи Turnstile всегда начинаются с 0x — это сразу отличает их от ключей reCAPTCHA на 6Le.
async function extractTurnstileSitekey(url) {
const resp = await fetch(url, {
headers: {
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36",
},
});
const html = await resp.text();
// Method 1: data-sitekey attribute on Turnstile div
const divMatch = html.match(
/class=["'][^"]*cf-turnstile[^"]*["'][^>]*data-sitekey=["']([0-9x][A-Za-z0-9_-]+)["']/
);
if (divMatch) return divMatch[1];
// Method 2: data-sitekey on any element (Turnstile keys start with 0x)
const attrMatch = html.match(
/data-sitekey=["'](0x[A-Za-z0-9_-]+)["']/
);
if (attrMatch) return attrMatch[1];
// Method 3: In JavaScript turnstile.render call
const jsMatch = html.match(
/turnstile\.render\s*\([^,]+,\s*\{[^}]*sitekey\s*:\s*["']([0-9x][A-Za-z0-9_-]+)["']/
);
if (jsMatch) return jsMatch[1];
// Method 4: Generic sitekey in inline script
const inlineMatch = html.match(
/sitekey\s*:\s*["'](0x[A-Za-z0-9_-]+)["']/
);
if (inlineMatch) return inlineMatch[1];
return null;
}
Если функция вернула null, страница почти наверняка подгружает виджет динамически. Разбор исходного HTML тут бесполезен — снимайте атрибут с уже отрендеренного DOM через Puppeteer или Playwright.
Шаг 2. Отправьте задачу в API CaptchaAI и опросите результат
Схема стандартная: POST на in.php с method=turnstile возвращает ID задачи, дальше вы опрашиваете res.php до готовности токена. Обязательных параметров два — sitekey и pageurl; action добавляется только тогда, когда сайт им пользуется.
const API_KEY = "YOUR_API_KEY";
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function solveTurnstile(sitekey, pageurl, action = null) {
// Submit task
const submitData = {
key: API_KEY,
method: "turnstile",
sitekey: sitekey,
pageurl: pageurl,
json: "1",
};
if (action) {
submitData.action = action;
}
const submitResp = await fetch("https://ocr.captchaai.com/in.php", {
method: "POST",
body: new URLSearchParams(submitData),
});
const submitResult = await submitResp.json();
if (submitResult.status !== 1) {
throw new Error(`Submit error: ${submitResult.request}`);
}
const taskId = submitResult.request;
console.log(`Task ID: ${taskId}`);
// Poll for result
for (let i = 0; i < 30; i++) {
await sleep(5000);
const pollResp = await fetch(
`https://ocr.captchaai.com/res.php?${new URLSearchParams({
key: API_KEY,
action: "get",
id: taskId,
json: "1",
})}`
);
const pollResult = await pollResp.json();
if (pollResult.status === 1) {
return pollResult.request;
}
if (pollResult.request === "ERROR_CAPTCHA_UNSOLVABLE") {
throw new Error("Turnstile unsolvable");
}
}
throw new Error("Solve timed out");
}
Здесь важны две детали. Первая — цикл ограничен 30 итерациями по 5 с, то есть жёстким тайм-аутом в 150 с; для Turnstile это с большим запасом. Вторая — ERROR_CAPTCHA_UNSOLVABLE стоит ловить отдельно и не тратить на него оставшиеся попытки: повторная отправка той же задачи ничего не изменит, а вот перечитать sitekey со страницы имеет смысл.
Шаг 3. Подставьте токен в поле cf-turnstile-response
Токен живёт недолго, поэтому форму отправляют сразу после получения ответа, а не складывают токены впрок. Имя поля фиксированное — cf-turnstile-response, сервер ищет именно его.
async function submitTurnstileForm(url, formData, token) {
const body = new URLSearchParams({
...formData,
"cf-turnstile-response": token,
});
const resp = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36",
},
body,
});
return {
status: resp.status,
body: await resp.text(),
};
}
Собираем сценарий входа целиком
Три функции выше складываются в один линейный сценарий: извлекли ключ, решили задачу, отправили форму. В таком виде интеграцию удобно проверять на собственном staging-стенде, прежде чем встраивать её в основной пайплайн.
async function loginWithTurnstile(loginUrl, credentials) {
// Step 1: Extract sitekey
const sitekey = await extractTurnstileSitekey(loginUrl);
if (!sitekey) {
throw new Error("Turnstile sitekey not found");
}
console.log(`Sitekey: ${sitekey}`);
// Step 2: Solve Turnstile
const token = await solveTurnstile(sitekey, loginUrl);
console.log(`Token: ${token.substring(0, 50)}...`);
// Step 3: Submit form
const result = await submitTurnstileForm(loginUrl, credentials, token);
console.log(`Result: ${result.status}`);
return result;
}
// Usage
const result = await loginWithTurnstile("https://staging.example.com/qa-login", {
email: "[email protected]",
password: "pass123",
});
Класс-решатель для production
Для постоянной работы линейный скрипт неудобен: API-ключ приходится таскать по аргументам, а логика отправки и опроса дублируется. Приватные поля класса снимают обе проблемы — ключ хранится в одном месте, а наружу торчат ровно два метода: solve() для известного sitekey и detectAndSolve() для страницы целиком.
class TurnstileSolver {
#apiKey;
constructor(apiKey) {
this.#apiKey = apiKey;
}
async solve(sitekey, pageurl, options = {}) {
const taskId = await this.#submit(sitekey, pageurl, options);
return await this.#poll(taskId);
}
async detectAndSolve(url) {
const sitekey = await this.#detect(url);
if (!sitekey) throw new Error("No Turnstile found");
return await this.solve(sitekey, url);
}
async #detect(url) {
const resp = await fetch(url, {
headers: { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0.0" },
});
const html = await resp.text();
const match = html.match(/data-sitekey=["'](0x[A-Za-z0-9_-]+)["']/);
return match ? match[1] : null;
}
async #submit(sitekey, pageurl, options) {
const body = new URLSearchParams({
key: this.#apiKey,
method: "turnstile",
sitekey,
pageurl,
json: "1",
...(options.action && { action: options.action }),
...(options.cdata && { data: options.cdata }),
});
const resp = await fetch("https://ocr.captchaai.com/in.php", {
method: "POST",
body,
});
const data = await resp.json();
if (data.status !== 1) throw new Error(`Submit: ${data.request}`);
return data.request;
}
async #poll(taskId) {
const params = new URLSearchParams({
key: this.#apiKey,
action: "get",
id: taskId,
json: "1",
});
for (let i = 0; i < 30; i++) {
await new Promise((r) => setTimeout(r, 5000));
const resp = await fetch(`https://ocr.captchaai.com/res.php?${params}`);
const data = await resp.json();
if (data.status === 1) return data.request;
if (data.request === "ERROR_CAPTCHA_UNSOLVABLE") {
throw new Error("Unsolvable");
}
}
throw new Error("Timed out");
}
}
// Usage
const solver = new TurnstileSolver("YOUR_API_KEY");
const token = await solver.detectAndSolve("https://staging.example.com/qa-login");
Ключ, разумеется, не хардкодят: читайте его из переменной окружения и держите вне репозитория.
Параметры action и cData
Часть внедрений Turnstile передаёт в виджет дополнительные параметры action и cData. Если они есть в разметке, а вы их не переслали, сервер отклонит внешне корректный токен. Ищите data-action в HTML или ключ action: в инлайн-скрипте:
// Extract action from the page
function extractTurnstileAction(html) {
const match = html.match(
/data-action=["']([^"']+)["']|action\s*:\s*["']([^"']+)["']/
);
return match ? match[1] || match[2] : null;
}
// Solve with action
const token = await solver.solve(sitekey, pageurl, {
action: "login",
cdata: "session_abc123",
});
Проверка токена на своей стороне
Если Turnstile стоит на вашем собственном сервисе и вы тестируете форму со стороны сервера, валидация делается запросом к siteverify от Cloudflare. Это уже не решение задачи CAPTCHA, а обратная сторона той же интеграции — полезно, когда нужно понять, почему конкретный токен принимается или отклоняется.
async function verifyTurnstileToken(token, ip) {
const resp = await fetch(
"https://challenges.cloudflare.com/turnstile/v0/siteverify",
{
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
secret: "YOUR_TURNSTILE_SECRET_KEY",
response: token,
remoteip: ip,
}),
}
);
const data = await resp.json();
return data.success;
}
Типичные ошибки и что с ними делать
| Симптом | Причина | Что сделать |
|---|---|---|
Sitekey начинается с 6Le |
Это reCAPTCHA, а не Turnstile | Переключитесь на method=userrecaptcha |
| Токен отклонён | Неверный или устаревший sitekey | Перечитайте sitekey и отправляйте форму быстрее |
| Sitekey не найден | Виджет подгружается через JavaScript | Снимайте ключ через Puppeteer или Playwright |
ERROR_BAD_PARAMETERS |
Не передан sitekey или pageurl | Проверьте оба параметра в теле запроса |
| 403 после отправки формы | Отсев по заголовкам запроса | Поставьте реалистичный User-Agent |
Отдельный случай — нестабильная мобильная сеть: если запросы к res.php периодически обрываются по тайм-ауту, добавьте экспоненциальную задержку между повторами вместо фиксированного интервала в 5 с.
Сценарий: ночной прогон регресс-тестов формы входа
Типовая задача QA-команды — ночной регресс по форме входа, на которой стоит Turnstile. Без решателя такой набор тестов либо падает целиком, либо заставляет отключать проверку на стенде, из-за чего staging перестаёт быть похожим на production. Класс TurnstileSolver встраивается прямо в фикстуру: перед сценарием входа вы получаете токен, кладёте его в тело запроса и дальше идёте обычным путём.
Если тестов, скажем, сорок и запускаются они в четыре параллельных потока, потоков плана BASIC ($15/мес, 5 потоков) хватает с запасом — потоки считаются по задачам в работе, а не по общему числу решений за месяц. Собирая логи таких прогонов, ограничьтесь техническими полями: тестовые учётные данные и любые персональные данные хранить незачем — для читателей из РФ это ещё и вопрос 152-ФЗ «О персональных данных».
Часто задаваемые вопросы
Нужен ли Puppeteer, если Turnstile решается через API?
Нет, пока sitekey виден в исходном HTML — достаточно fetch. Браузер нужен только тогда, когда виджет вставляется скриптом и в исходной разметке ключа просто нет.
Почему сервер возвращает 403 уже после того, как токен получен?
Токен здесь чаще всего ни при чём. Проверьте заголовки: запрос без внятного User-Agent или без нужных cookie отсеивается раньше, чем дело доходит до проверки поля cf-turnstile-response.
Сколько потоков брать под парсер с Turnstile?
Считайте по одновременным задачам, а не по объёму за месяц. Одиночный скрипт закрывается BASIC ($15/мес, 5 потоков), десятки параллельных сессий — ADVANCE ($90/мес, 50 потоков); число решений внутри плана не лимитировано.
Какие ещё типы CAPTCHA закрываются тем же API?
Через тот же in.php/res.php доступны Turnstile, Cloudflare Challenge, reCAPTCHA v2 и v3, GeeTest v3, а также CaptchaFox (beta), Friendly Captcha (beta) и Lemin (beta). А вот hCaptcha и FunCaptcha не поддерживаются, GeeTest v4 значится как «скоро» — под них этот сценарий не переиспользуется.
Долго ли ждать токен Turnstile?
Ориентир по времени решения — менее 10 с, поэтому опрос с шагом в 5 с обычно завершается на второй-третьей итерации. Тайм-аут в скрипте при этом стоит держать заметно выше: 30 попыток по 5 с дают запас на медленную сеть.
Коротко
Turnstile в Node.js сводится к трём шагам: найти на странице sitekey на 0x, получить токен через API CaptchaAI с method=turnstile и отправить его в поле cf-turnstile-response. Всё остальное — обвязка: тайм-ауты, повторы и аккуратное хранение API-ключа.