Tutorials

Решение Cloudflare Turnstile в Node.js через API CaptchaAI

Чтобы пройти проверку 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-ключа.

Читайте дальше

Комментарии для этой статьи отключены.