Как создать детектор капчи на Node.js и решить её

captcha detector in node.js - How to Build a CAPTCHA Detector in Node.js and Solve It

Детектор капчи на Node.js читает в HTML страницы скрипт и контейнер виджета, достаёт из них sitekey и сопоставляет семейство с методом CapSkip, который его решает. Для чекбокса reCAPTCHA v2, скрипта v3 и виджета Turnstile нужны три разных вызова с разными параметрами, а неверная догадка тратит решение впустую и даёт результат, который вам не пригодится. В этом руководстве собирается детектор примерно в 165 строк без пакетов, кроме capskip. Он возвращает тип, метод и аргументы или прямо говорит, что определить не может. CapSkip работает на вашем собственном ПК с Windows без платы за каждое решение, поэтому скрипт, проверяющий сто страниц, ничего не добавляет к расходам. Тот же детектор на Python описан в руководстве по определению типа капчи.

Что понадобится

  • CapSkip, запущенный на ПК с Windows, с включёнными нужными вам решателями. Пошаговое руководство по настройке проведёт вас через первый запуск.
  • Node.js 20 или новее. Код ниже запускался на Node.js 24.22.0, а детектор и шаг решения также запускались на 20.11.1 и 25.2.1, с capskip 1.3.0. Сам пакет требует Node.js 18 или новее, но Node.js 18 здесь не пробовали.
  • Окно PowerShell. Команды написаны в синтаксисе PowerShell.
  • Страница, которой вы владеете или которую вам разрешено автоматизировать, с капчей на ней.
  • Адрес решателя. В режиме Local CapSkip отвечает на 127.0.0.1 и только для этого устройства. В режиме Server он слушает ваш сетевой адрес или публичный IP, чтобы скрипт на другом ПК, VPS или размещённой платформе мог обращаться к нему через API. Оба режима задаются в разделе Настройки подключения.

Как это проверялось: детектор запускался на тех же 66 сохранённых страницах, что и версия на Python, и вернул тот же результат для каждой страницы. Из них 59 собраны вручную (около 40 следуют документации поставщиков капчи, остальные придуманные мной крайние случаи), а 7 являются снимками публичных страниц от 29 октября 2026 года. Решения выполнялись через настоящий пакет capskip на имитации CapSkip, которая копирует проверки входных данных приложения. Настоящее приложение не запускалось, поэтому ни одно число ниже не является временем решения.

Зачем определять тип капчи до решения

В CapSkip для каждого семейства свой метод: recaptcha, turnstile, geetest, altcha, capy, captchafox, friendlyCaptcha и normal для изображений в SDK, плюс hcaptcha в HTTP API, для которого в SDK пока нет обёртки. Внутри reCAPTCHA важны и параметры. Invisible, Enterprise и v3 являются флагами того же вызова. Если передать не те, в лучшем случае задание завершится ошибкой, в худшем вы получите токен, который сайт отклонит без объяснений. Я не смог проверить, что именно делает настоящее приложение при неверной версии reCAPTCHA. В любом случае решение потрачено.

Остальные ошибки проявляются быстро и громко. Если взять sitekey со страницы, где reCAPTCHA рендерится явно, получится слово explicit, и CapSkip отклонит его с ошибкой ERROR_GOOGLEKEY. Эту ошибку разбирает руководство по ERROR_GOOGLEKEY и ERROR_PAGEURL с другой стороны. Чтобы надёжно определять тип капчи, детектор должен проверять признаки в фиксированном порядке, и такой порядок исключает оба вида ошибок. Детектор к тому же стоит написать первым, прежде чем делать парсер, который встречает больше одного сайта. Если вам нужна общая картина того, что представляет собой каждый тип, статья о том, что такое капча разбирает все семейства без кода.

Шаг 1: установка и загрузка страницы

Создайте папку, инициализируйте проект и установите capskip. Детектор использует только регулярные выражения, а запросы выполняются через fetch, который поставляется с Node.js.

npm init -y
npm install capskip

Детектор капчи на Node.js работает с текстом, поэтому ему нужны только два входных значения: HTML страницы и её адрес. Обычный fetch показывает то, что сервер отправляет до запуска любого JavaScript. Демо-страницы reCAPTCHA от Google, демо Turnstile от Cloudflare и демо hCaptcha несут свой виджет прямо в этом HTML. Страницы, которые добавляют виджет через JavaScript, так не делают, и этот случай разбирает раздел “Когда HTML ничего не говорит”. fetch в Node.js не хранит cookies, что важно для шага с капчей-изображением, поэтому скрипт ниже передаёт их вручную.

Шаг 2: пишем вспомогательные функции

Сохраните всё отсюда до шага 9 в один файл detect.js, блок за блоком. Первый блок пишет небольшие функции чтения. Каждая возвращает null, если ничего не найдено, поэтому отсутствующий атрибут никогда не вызывает ошибку.

'use strict';
const SITEKEY_JS = /sitekey['"]?\s*:\s*['"]([^'"]+)/; // sitekey: '...' inside a render() call
const NAMED = { '&amp;': '&', '&lt;': '<', '&gt;': '>', '&quot;': '"', '&apos;': "'" };
const unescapeHtml = (s) => s.replace(/&(?:#x([0-9a-f]+)|#(\d+)|[a-z]+);/gi, (m, hex, dec) =>
  hex || dec ? String.fromCodePoint(parseInt(hex || dec, hex ? 16 : 10)) : NAMED[m.toLowerCase()] || m);
function grab(re, text) { // first capture group that matched, or null
  const m = new RegExp(re.source, 'is').exec(text || '');
  return m ? m.slice(1).find((g) => g !== undefined) ?? null : null;
}
const tag = (cls, h) => grab(new RegExp( // opening tag that has the CSS class cls
  `(<\\w+[^>]*\\sclass\\s*=\\s*["'][^"']*(?<![\\w-])${cls}(?![\\w-])[^"']*["'][^>]*>)`), h);
function attr(t, name) { // attribute value from a tag string, either quote style, unescaped
  const v = grab(new RegExp(`\\s${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)')`), t);
  return v === null ? null : unescapeHtml(v);
}
const renderOpts = (call, js) => grab(new RegExp(`${call}\\([^,]+,\\s*\\{(.{0,400})`), js);

Функция grab возвращает первую совпавшую группу захвата. Функция tag находит открывающий тег по CSS-классу и отвергает класс, который лишь входит в состав более длинного, поэтому класс cf-turnstile-response не считается виджетом Turnstile. Функция attr читает атрибут, записанный в любом стиле кавычек, и раскодирует HTML-сущности, что важно для проверки, хранящейся в атрибуте. Атрибуты без кавычек не читаются.

Второй блок превращает страницу в то, чем могут пользоваться все проверки. Он удаляет комментарии и блоки стилей, чтобы CSS-правило с упоминанием класса виджета не могло дать совпадение, а адреса скриптов и встроенный JavaScript собирает отдельно. Две вспомогательные функции после parse формируют ответы: hit и browser, который несёт причину needsBrowser. Третий ответ, unsupported, формируется проверками в конце этого блока, о чём рассказывает следующий шаг вместе с проверкой hCaptcha.

function parse(html, url) {
  const h = html.replace(/<!--.*?-->|<style\b.*?<\/style>/gis, '');
  const srcs = [...h.matchAll(/<script\b[^>]*?\ssrc\s*=\s*["']([^"']+)/gi)].map((m) => m[1]).join(' ');
  const js = [...h.matchAll(/<script\b[^>]*>(.*?)<\/script>/gis)].map((m) => m[1]).join('\n');
  return { h, srcs: unescapeHtml(srcs), js, url };
}
const hit = (type, method, args) => ({ type, method, args });
const browser = (type, method, p, why) => ({ type, method, args: { url: p.url }, needsBrowser: why });

const UNSUPPORTED = { arkose: /arkoselabs\.com\/v2\/|funcaptcha\.com\/fc\//, geetest_v4: /gt4\.js|initGeetest4/ };
function unsupported(p) { // CapSkip cannot solve these: say so instead of misleading the reader
  const type = Object.keys(UNSUPPORTED).find((k) => UNSUPPORTED[k].test(p.srcs + ' ' + p.js));
  if (type) return { type, method: null, args: {}, unsupported: true };
}
const cfChallenge = (p) => /_cf_chl_opt\s*=\s*\{/.test(p.js) // the full-page "Just a moment..." page
  ? browser('turnstile_challenge', 'turnstile', p, 'data and pagedata (Cloudflare calls them cData and chlPageData) only exist at run time') : null;
function hcaptcha(p) { // before reCAPTCHA: a site that moved to hCaptcha may keep the g-recaptcha class
  if (!/hcaptcha\.com\/1\/api\.js/.test(p.srcs + ' ' + p.js)) return null;
  if (/\.setData\s*\(|rqdata/.test(p.js)) // Enterprise rqdata is issued per challenge
    return browser('hcaptcha_enterprise', 'hcaptcha', p, 'rqdata is issued for one challenge at run time');
  const w = tag('h-captcha', p.h) || tag('g-recaptcha', p.h);
  const key = attr(w, 'data-sitekey') || grab(SITEKEY_JS, renderOpts('hcaptcha\\.render', p.js));
  if (!key) return browser('hcaptcha', 'hcaptcha', p, 'no sitekey in the static HTML');
  const args = { sitekey: key, url: p.url };
  if (attr(w, 'data-size') === 'invisible') args.invisible = 1;
  return hit('hcaptcha', 'hcaptcha', args);
}

Шаг 3: hCaptcha и то, что CapSkip решить не может

Эти три проверки находятся в конце второго блока выше, поэтому на этом шаге вставлять ничего нового не нужно. Начнём со страниц, на которых правильный ответ «нет». CapSkip не решает FunCaptcha и Arkose, а GeeTest v4 не входит в метод GeeTest, который рассчитан на v3. Если бы детектор их пропускал, страница с Arkose попадала бы в следующую проверку и могла бы вернуться как нечто другое. Поэтому эта проверка идёт первой и возвращает результат без метода.

Тот же блок обрабатывает полностраничную проверку Cloudflare, промежуточную страницу “Just a moment”. Это не виджет Turnstile. Методу turnstile нужны от неё два значения, data и pagedata, а обычный fetch их не получает. Детектор ищет встроенные в страницу параметры проверки и отвечает needsBrowser. Что делать дальше, описывает руководство по cf_clearance после того, как страница проверки пройдена.

Следующей идёт hCaptcha, раньше reCAPTCHA. CapSkip решает её, будь виджет чекбоксом, невидимым или Enterprise, но сайт, перешедший с reCAPTCHA на hCaptcha, часто оставляет на виджете класс g-recaptcha, и отличить одно от другого можно только по скрипту hCaptcha. Проверка читает sitekey из виджета или из вызова hcaptcha.render и добавляет invisible, если у виджета data-size равен invisible. Страница Enterprise, которая передаёт виджету значение rqdata через hcaptcha.setData, вместо этого получает needsBrowser, потому что это значение выдаётся для одной проверки и быстро истекает.

Шаг 4: reCAPTCHA, в правильном порядке

reCAPTCHA требует больше всего внимания, потому что несколько вариантов CapSkip используют два имени скрипта. Разделяет их порядок вопросов. Сначала имя скрипта: enterprise.js означает флаг Enterprise, api.js нет. Затем параметр render в адресе скрипта. Ключ после render= означает v3, а слово explicit после render= означает страницу v2, которая создаёт виджет из кода, и использовать его как ключ нельзя ни в коем случае. Затем разметка. Кнопка с классом g-recaptcha и атрибутом data-action соответствует привязке кнопки v3 от Google. Кнопка без data-action или любой элемент с data-size, равным invisible, является невидимым виджетом v2. Всё остальное с этим классом является чекбоксом.

function recaptcha(p) { // Enterprise flag first, then v3 (render=KEY), then invisible, then v2
  const m = /\/recaptcha\/(api|enterprise)\.js(\?\S*)?/.exec(p.srcs);
  if (!m) return null;
  const ent = m[1] === 'enterprise' ? { enterprise: 1 } : {};
  const render = grab(/[?&]render=([^&\s]+)/, m[2]); // a v3 sitekey, or "explicit" (a v2 page)
  const t = tag('g-recaptcha', p.h), o = renderOpts('\\.render', p.js);
  const button = (t || '').toLowerCase().startsWith('<button'), dataAction = attr(t, 'data-action');
  const v3 = Boolean(render && render !== 'explicit') || Boolean(button && dataAction);
  const key = (render !== 'explicit' ? render : null) || attr(t, 'data-sitekey') || grab(SITEKEY_JS, o);
  if (!key) return browser('recaptcha', 'recaptcha', p, 'no sitekey in the static HTML (set by JavaScript)');
  const small = attr(t, 'data-size') === 'invisible' || grab(/size['"]?\s*:\s*['"](invisible)/, o);
  const invisible = !v3 && Boolean(button || small);
  const action = dataAction || grab(/execute\(\s*[^,)]+,\s*\{\s*['"]?action['"]?\s*:\s*['"]([^'"]+)/, p.js);
  const args = { sitekey: key, url: p.url, ...ent };
  if (v3) Object.assign(args, { version: 'v3' }, action ? { action } : {});
  if (invisible) args.invisible = 1;
  const kind = 'recaptcha_' + (v3 ? 'v3' : invisible ? 'v2_invisible' : 'v2');
  return hit(kind + (ent.enterprise ? '_enterprise' : ''), 'recaptcha', args);
}

Каждый исход соответствует знакомому вызову. Чекбокс вызывается как solver.recaptcha с sitekey, URL страницы и пустым объектом параметров. Для invisible добавляется invisible, равное 1, для Enterprise добавляется enterprise, равное 1, а для v3 добавляются version, равная v3, и action. Имя action берётся из атрибута data-action или из вызова execute на странице; если в HTML нет ни того, ни другого, параметр опускается, и CapSkip использует значение по умолчанию verify. Подробности смотрите на странице решателя reCAPTCHA с описанием каждого поддерживаемого варианта.

Шаг 5: Turnstile и GeeTest

Виджету Turnstile на странице нужны две вещи: api.js от Cloudflare и sitekey. Ключ лежит в атрибуте data-sitekey у div с классом cf-turnstile или в вызове turnstile.render. Проверка требует наличия скрипта, поэтому случайное имя класса на постороннем сайте не вызовет срабатывание. Она также читает data-action и data-cdata, если они есть, и передаёт их дальше. Подробности смотрите на странице решателя Turnstile с полным списком параметров.

GeeTest v3 даёт два значения, и одно из них ловушка. Значение gt постоянно для сайта и может лежать на странице. Значение challenge одноразовое, и сайты получают его с эндпоинта register во время выполнения. Если на странице есть оба значения, детектор возвращает их. Если там только вызов initGeetest, он отвечает needsBrowser, потому что challenge, найденный в старом HTML, уже был бы недействителен.

function turnstile(p) { // widget only: implicit (div.cf-turnstile) or explicit (turnstile.render)
  if (!p.srcs.includes('challenges.cloudflare.com/turnstile/v0/api.js')) return null;
  const t = tag('cf-turnstile', p.h), o = renderOpts('turnstile\\.render', p.js);
  const sitekey = attr(t, 'data-sitekey') || grab(SITEKEY_JS, o);
  if (!sitekey) return browser('turnstile', 'turnstile', p, 'no sitekey in the static HTML');
  const action = attr(t, 'data-action') || grab(/\baction['"]?\s*:\s*['"]([^'"]+)/, o);
  const data = attr(t, 'data-cdata') || grab(/\bcData['"]?\s*:\s*['"]([^'"]+)/, o);
  return hit('turnstile', 'turnstile', { sitekey, url: p.url, ...(action && { action }), ...(data && { data }) });
}
function geetest(p) { // v3 only: gt is static, challenge comes from the register endpoint
  if (!(/initGeetest\s*\(/.test(p.js) || /\/gt(\.[\d.]+)?\.js/.test(p.srcs))) return null;
  const gt = grab(/\bgt['"]?\s*:\s*['"]([0-9a-f]{32})/, p.js);
  const challenge = grab(/\bchallenge['"]?\s*:\s*['"]([0-9a-f]{32,34})/, p.js);
  if (!(gt && challenge)) return browser('geetest', 'geetest', p, 'gt and challenge come from the register endpoint');
  return hit('geetest', 'geetest', { gt, challenge, url: p.url });
}

Шаг 6: ALTCHA и Capy

ALTCHA менял имена атрибутов от поколения к поколению. Версии 1 и 2 используют challengeurl или challengejson для встроенной проверки. Версия 3 и новее используют один атрибут challenge, который принимает либо URL, либо данные проверки. Проверка читает все три и присоединяет относительный URL к адресу страницы. В Node.js параметры называются challengeUrl и challengeJson.

Capy не нужен div виджета для этой проверки. Его ключ начинается с PUZZLE_ и лежит в параметре k адреса скрипта виджета, а хост, отличный от стандартного, передаётся как apiServer.

function altcha(p) { // challengeurl / challengejson (v1, v2) or challenge (v3+)
  const t = grab(/(<altcha-widget\b[^>]*>)/, p.h);
  if (!t) return null;
  const c = attr(t, 'challenge');
  const asJson = attr(t, 'challengejson') || ((c || '').trimStart().startsWith('{') ? c : null);
  const asUrl = attr(t, 'challengeurl') || (asJson ? null : c);
  if (asJson) return hit('altcha', 'altcha', { url: p.url, challengeJson: asJson });
  if (asUrl) return hit('altcha', 'altcha', { url: p.url, challengeUrl: new URL(asUrl, p.url).href });
  return browser('altcha', 'altcha', p, 'no challenge attribute');
}
function capy(p) { // the widget script is .../puzzle/get_js/?k=PUZZLE_...
  const m = /((?:https?:)?\/\/[^\s"']+?)\/puzzle\/get_js\/\S*?(PUZZLE_[\w-]+)/.exec(p.srcs);
  if (!m) return null;
  const host = m[1].startsWith('http') ? m[1] : 'https:' + m[1];
  const more = host === 'https://jp.api.capy.me' ? {} : { apiServer: host };
  return hit('capy', 'capy', { sitekey: m[2], url: p.url, ...more });
}

Шаг 7: CaptchaFox и Friendly Captcha

CaptchaFox выпускается в двух сборках. Обычная загружается с cdn.captchafox.com. Другую упаковывает хостинг-платформа под общим путём CDN, поэтому детектор ищет в этом пути имя пакета CaptchaFox, а не сам путь, который используют и другие виджеты.

Friendly Captcha самая коварная. Версии 1 и 2 используют один и тот же div и одно пространство sitekey, но разные протоколы, и токен для неверной версии сайт может отклонить. Скрипты их различают: версия 2 загружает пакет @friendlycaptcha/sdk, версия 1 загружает friendly-challenge. Если страница не даёт признаков, детектор отказывается гадать и отвечает needsBrowser.

const MAM = 'mampkg/@mamdev/core.frontend.libs.captchafox';
function captchafox(p) { // plain build from cdn.captchafox.com, or the bundled build from s.uicdn.com
  const t = tag('captchafox', p.h), key = grab(/captchafox[^"']*\/api\.js\?key=([^"'&]+)/, p.srcs);
  if (!t && !key) return null;
  const more = p.srcs.includes(MAM) ? { apiServer: 'https://s.uicdn.com/' + MAM + '/' } : {};
  const sitekey = attr(t, 'data-sitekey') || key;
  if (!sitekey) return browser('captchafox', 'captchafox', p, 'no sitekey in the static HTML');
  return hit('captchafox', 'captchafox', { sitekey, url: p.url, ...more });
}
function friendly(p) { // v2 loads @friendlycaptcha/sdk (site.min.js), v1 loads friendly-challenge
  const t = tag('frc-captcha', p.h);
  if (!t) return null;
  const v2End = attr(t, 'data-api-endpoint'), v1End = attr(t, 'data-puzzle-endpoint');
  const v2 = v2End !== null || /friendlycaptcha\/sdk|site(\.compat)?\.min\.js/.test(p.srcs);
  const v1 = v1End !== null || /friendly-challenge|widget(\.module)?\.min\.js/.test(p.srcs);
  if (v2 === v1) return browser('friendly_captcha', 'friendlyCaptcha', p, 'cannot tell v1 from v2 in the HTML');
  const server = v2End || v1End;
  if (!attr(t, 'data-sitekey')) return browser('friendly_captcha', 'friendlyCaptcha', p, 'no sitekey in the static HTML');
  return hit('friendly_captcha_' + (v2 ? 'v2' : 'v1'), 'friendlyCaptcha',
    { sitekey: attr(t, 'data-sitekey'), url: p.url, version: v2 ? 'v2' : 'v1', ...(server && { apiServer: server }) });
}

У каждого из них есть своя страница решателя с параметрами, которые принимает метод:

Шаг 8: капча-изображение, предположение и проверки

У капчи-изображения нет скрипта поставщика, который можно найти, поэтому эта проверка является предположением и запускается, только если ничего другого не совпало. Проверка ищет изображение, в теге которого есть captcha, и текстовое поле в другом месте страницы, в теге которого есть captcha или security code. Она промахивалась во всех случаях, где в разметке этих слов не было, а нарисованный canvas для неё невидим. Считайте срабатывание вероятным, но не достоверным.

Тот же блок перечисляет проверки по порядку и определяет detect, который экспортируется в конце шага 9. Порядок и есть логика: сначала неподдерживаемые поставщики, затем страница Cloudflare, затем hCaptcha, затем каждое другое семейство, и предположение про изображение только если список ничего не нашёл. Функция detect возвращает первый результат и добавляет список also, если на странице больше одного семейства, например скрипт v3 плюс виджет Turnstile. Список называет семейства, а не второй виджет reCAPTCHA с другим ключом.

function image(p) { // a guess: an <img> that says captcha, next to a text box that says captcha
  const box = /<input\b(?![^>]*type\s*=\s*["']?(?:hidden|checkbox|submit))[^>]*(?:captcha|security.?code)/i;
  const img = (p.h.match(/<img\b[^>]*>/gi) || []).find((i) => box.test(p.h) && /captcha/i.test(i) && attr(i, 'src'));
  if (img) return hit('image', 'normal', { image: new URL(attr(img, 'src'), p.url).href });
}
const CHECKS = [unsupported, cfChallenge, hcaptcha, recaptcha, turnstile, geetest, altcha, capy, captchafox, friendly];

function detect(html, pageUrl) {
  const p = parse(html, pageUrl);
  const found = CHECKS.map((check) => check(p)).filter(Boolean);
  const best = found[0] || image(p) || null; // the image guess comes last
  if (found.length > 1) best.also = found.slice(1).map((r) => r.type);
  return best;
}

Шаг 9: решение

Последний блок передаёт результат нужному методу CapSkip. Он отказывается обрабатывать всё unsupported или помеченное needsBrowser и выбрасывает Error. Методы CapSkip принимают основные значения по позиции, а остальное как объект параметров, поэтому небольшая таблица показывает, какие значения куда идут. Параметры Node.js пишутся в camelCase, с одним исключением: если вы добавляете интервал опроса на отдельный вызов, имя будет polling_interval, потому что pollingInterval на отдельный вызов выбрасывает ValidationException. Метод для изображений вообще не принимает параметров на вызов, поэтому для него задайте pollingInterval в конструкторе CapSkip. Изображение скачивается с теми cookies, которые вы передаёте, потому что у fetch нет хранилища cookies, а собственная загрузка URL в SDK их не отправляет. Эти cookies уходят на тот хост, где лежит изображение, поэтому, если изображение капчи лежит на другом хосте, не передавайте cookie.

В SDK пока нет вызова для hCaptcha, поэтому результат hCaptcha вместо этого уходит в solveHcaptcha. Функция отправляет sitekey и адрес страницы в in.php с method=hcaptcha, затем каждые 5 секунд опрашивает res.php, пока токен не будет готов, как описано в разделе hCaptcha документации API на сайте. Передайте адрес решателя как api в последнем аргументе solve, а ваш API key как key, если включена проверка API key. Результат несёт токен в code и User-Agent, с которым он получен, в userAgent.

const POSITIONAL = { normal: ['image'], recaptcha: ['sitekey', 'url'], turnstile: ['sitekey', 'url'],
  geetest: ['gt', 'challenge', 'url'], altcha: ['url'], capy: ['sitekey', 'url'], captchafox: ['sitekey', 'url'],
  friendlyCaptcha: ['sitekey', 'url'] };

async function solveHcaptcha(args, api, key) { // no SDK call yet: post to in.php, poll res.php
  const form = { method: 'hcaptcha', sitekey: args.sitekey, pageurl: args.url, invisible: args.invisible || 0, json: 1 };
  if (args.data) form.data = args.data;
  if (key) form.key = key;
  const job = await (await fetch(`${api}/in.php`, { method: 'POST', body: new URLSearchParams(form) })).json();
  if (job.status !== 1) throw new Error(job.request);
  for (let i = 0; i < 36; i++) { // a solve takes 10 to 30 seconds, so give up after 3 minutes
    await new Promise((done) => setTimeout(done, 5000));
    const q = new URLSearchParams({ action: 'get', id: job.request, json: 1, ...(key ? { key } : {}) });
    const r = await (await fetch(`${api}/res.php?${q}`)).json();
    if (r.request !== 'CAPCHA_NOT_READY') {
      if (r.status !== 1) throw new Error(r.request);
      return { code: r.request, userAgent: r.useragent };
    }
  }
  throw new Error('hCaptcha was not solved within 3 minutes');
}

async function solve(info, solver, fetchOpts = {}, { api = 'http://127.0.0.1:8080', key } = {}) {
  if (info.unsupported || info.needsBrowser) throw new Error(info.needsBrowser || `${info.type} is not supported`);
  const args = { ...info.args };
  if (info.method === 'hcaptcha') return solveHcaptcha(args, api, key);
  if (info.method === 'normal' && !args.image.startsWith('data:')) { // fetch has no cookie jar: pass a Cookie header
    const res = await fetch(args.image, fetchOpts);
    if (!res.ok) throw new Error(`image download failed: HTTP ${res.status}`);
    args.image = 'data:image/png;base64,' + Buffer.from(await res.arrayBuffer()).toString('base64');
  }
  const names = POSITIONAL[info.method];
  const options = Object.fromEntries(Object.entries(args).filter(([k]) => !names.includes(k)));
  return solver[info.method](...names.map((k) => args[k]), options);
}
module.exports = { detect, solve };

Запуск детектора капчи на Node.js

Сохраните это как use.js рядом с detect.js. Скрипт загружает вашу страницу, печатает то, что нашёл детектор, и вызывает CapSkip, только когда есть метод для вызова и ничего не пропущено. Он отправляет User-Agent, похожий на браузерный, потому что многие сайты отказывают стандартному User-Agent Node.js. Он печатает HTTP-статус, если страница отвечает не 200, и продолжает работу, потому что страница проверки Cloudflare приходит как 403 или 503. Запускайте с адресом страницы в кавычках, потому что адрес с амперсандом в PowerShell является ошибкой разбора. Порт 8080 используется по умолчанию; если у вас другой, задайте переменную окружения CAPSKIP_PORT, которую этот скрипт читает сам. Хост в этом скрипте остаётся 127.0.0.1, поэтому для режима Server измените строку с хостом.

$env:CAPSKIP_PORT = "9090"   # only if your port is not 8080
node use.js "https://example.com/login"

Учтите, что fetch не хранит cookies, а этот скрипт читает Set-Cookie только из итогового ответа, поэтому cookie, установленная при перенаправлении, теряется. Чтобы сохранить её, вызывайте fetch с параметром redirect, равным manual, читайте Set-Cookie из ответа с перенаправлением и запрашивайте адрес из Location сами. Сам скрипт приведён ниже.

'use strict';
const { CapSkip } = require('capskip');
const { detect, solve } = require('./detect');

async function main(pageUrl) {
  const res = await fetch(pageUrl, { headers: { 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/130.0 Safari/537.36' } });
  if (!res.ok) console.error(`page answered HTTP ${res.status}`); // a challenge page is a 403 or 503, so keep going
  const html = await res.text();
  const info = detect(html, res.url); // res.url is the final address after any redirect
  console.log(JSON.stringify(info, null, 2));
  if (!info || info.unsupported || info.needsBrowser) return;
  const cookie = res.headers.getSetCookie().map((c) => c.split(';')[0]).join('; ');
  const host = '127.0.0.1', port = Number(process.env.CAPSKIP_PORT || 8080);
  const solver = new CapSkip({ host, port });
  console.log(await solve(info, solver, cookie ? { headers: { cookie } } : {}, { api: `http://${host}:${port}` }));
}

main(process.argv[2]).catch((err) => {
  console.error(`${err.constructor.name}: ${err.message}`);
  process.exit(1);
});

На странице с чекбоксом reCAPTCHA v2 первый вывод показывает тип, метод и аргументы, а второй показывает результат. Для reCAPTCHA токен лежит в поле code; другие методы используют другие поля, например userAgent рядом с токенами Turnstile и hCaptcha. Токен reCAPTCHA кладётся в поле g-recaptcha-response формы, а токен hCaptcha в h-captcha-response. В запуске на имитации детектор вернул такие результаты:

Страницаdetect возвращает
reCAPTCHA v2 checkboxtype recaptcha_v2, method recaptcha, sitekey и url
Виджет hCaptchatype hcaptcha, method hcaptcha, sitekey и url
Страница Cloudflare “Just a moment”type turnstile_challenge, задан needsBrowser
Скрипт v3 и виджет Turnstiletype recaptcha_v3, в also указан turnstile

Когда HTML ничего не говорит: консоль браузера

Некоторые страницы добавляют виджет через JavaScript после загрузки. Обычная загрузка тогда не находит капчу вообще, и детектор возвращает null для страницы, на которой она есть. Известны два ожидаемых случая: скрипт Google или Cloudflare, добавленный кодом, и sitekey, заданный кодом. Для них откройте страницу в Chrome, нажмите F12 и вставьте это в Console. Если Chrome блокирует вставку, сначала введите allow pasting и нажмите Enter. Сниппет читает sitekey из ресурсов, которые страница уже загрузила, поэтому видит то, что построил браузер.

(() => {
  const iframes = [...document.querySelectorAll('iframe')].map((f) => f.src);
  const urls = performance.getEntriesByType('resource').map((r) => r.name).concat(iframes);
  const found = new Map(), add = (o) => found.set(JSON.stringify(o), o);
  for (const u of urls) {
    let m;
    if ((m = u.match(/recaptcha\/(?:api2|(enterprise))\/anchor\?.*?\bk=([\w-]+)/)))
      add({ type: 'recaptcha', sitekey: m[2], enterprise: !!m[1], invisible: /size=invisible/.test(u) });
    else if ((m = u.match(/recaptcha\/(?:api|enterprise)\.js\?.*?\brender=([\w-]+)/)) && m[1] !== 'explicit')
      add({ type: 'recaptcha v3', sitekey: m[1], enterprise: /enterprise\.js/.test(u) });
    else if ((m = u.match(/challenges\.cloudflare\.com\/.*turnstile\/.*\/(0x4[\w-]+|[123]x0+[A-Za-z]+)\//)))
      add({ type: 'turnstile', sitekey: m[1] });
    else if ((m = u.match(/hcaptcha\.com\/captcha\/.*[#&?]sitekey=([\w-]+)/))) add({ type: 'hcaptcha', sitekey: m[1] });
    else if (/arkoselabs\.com\/fc\//.test(u)) add({ type: 'arkose: CapSkip cannot solve' });
  }
  if (window._cf_chl_opt) add({ type: 'cloudflare challenge page' });
  document.querySelectorAll('altcha-widget').forEach((e) => add({ type: 'altcha', challenge: e.getAttribute('challenge') || e.getAttribute('challengeurl') }));
  document.querySelectorAll('.h-captcha[data-sitekey]').forEach((e) => add({ type: 'hcaptcha', sitekey: e.dataset.sitekey }));
  document.querySelectorAll('.frc-captcha, .captchafox').forEach((e) => add({ type: e.classList.contains('captchafox') ? 'captchafox' : 'frc-captcha', sitekey: e.dataset.sitekey }));
  const v3 = [...found.values()].filter((o) => o.type === 'recaptcha v3').map((o) => o.sitekey);  // a v3 badge also adds an invisible anchor frame
  const res = [...found.values()].filter((o) => !(o.type === 'recaptcha' && v3.includes(o.sitekey)));
  return res.length ? res : 'no known CAPTCHA found (GeeTest, Capy and CaptchaFox scripts are not checked)';
})();

Он нашёл sitekey и флаги на странице reCAPTCHA v2, на невидимой, на странице v3, на странице Turnstile и на демо hCaptcha, а на странице без капчи вывел сообщение. Три ограничения: браузер по умолчанию хранит только около 250 записей ресурсов, поэтому долго работающее одностраничное приложение может потерять ранние, и сниппет не отличает Friendly Captcha версии 1 от версии 2, и не ищет скрипты GeeTest, Capy или CaptchaFox. На полностраничной проверке Cloudflare он может показать sitekey из адреса ресурса, но не значения data и pagedata, для которых нужен перехват вызова render. Этот перехват здесь не проверялся.

Где определение даёт сбой

СитуацияЧто возвращает detectЧто делать
Скрипт виджета, добавленный через JavaScriptnullЗагрузите страницу в браузере и запустите сниппет для консоли
Sitekey, заданный через JavaScriptneedsBrowserПрочитайте ключ с живой страницы
Полностраничная проверка CloudflareneedsBrowserПрочитайте значения в браузере, см. руководство по cf_clearance. Страница приходит как 403 или 503, о чём use.js сообщает, а затем всё равно продолжает проверку
Скрипт CaptchaFox, Friendly Captcha или Capy без виджета в HTMLnullЗапустите сниппет для консоли или прочитайте исходный код страницы
Страница блокировки (403, 503) без виджетаnulluse.js сначала печатает статус: на странице блокировки нет капчи, которую можно найти
Arkose, GeeTest v4unsupportedCapSkip не может их решить
hCaptcha Enterprise, которая задаёт rqdataneedsBrowserДля каждого задания читайте свежий rqdata с живой страницы и передавайте его в solveHcaptcha как data
Friendly Captcha без подсказки в скриптеneedsBrowserПроверьте, какой скрипт загружает страница
Капча-изображение без слова captchanullДобавьте собственное правило для этого сайта

Частые ошибки

ОшибкаПричинаИсправить
ERROR_GOOGLEKEYКлючом оказалось слово explicit или ключ, не относящийся к reCAPTCHAЧитайте ключ из виджета, а не из параметра render
ERROR_CAPTCHA_UNSOLVABLEНеудавшееся задание выглядит так, и в ответе причина не названа. Неверная версия reCAPTCHA может привести сюда, но я не подтвердил это на настоящем приложенииСначала проверьте определённый тип, затем откройте раздел Документация API
ValidationExceptionПараметр, которого метод не принимает, например action в вызове v2, pollingInterval на отдельный вызов или любой параметр на отдельный вызов у метода для изображенийИспользуйте аргументы, которые вернул детектор
NetworkExceptionCapSkip не запущен либо неверно указаны хост или портЗапустите CapSkip и проверьте настройки подключения
Error из solveРезультат unsupported или нужен браузер, либо CapSkip отклонил задание hCaptcha, и сообщение содержит его код ошибкиОбработайте первые два случая до вызова solve, а для третьего проверьте sitekey и адрес страницы

Запуск решателя на сервере

Детектору от CapSkip нужны только две вещи: хост и порт. В режиме Local это 127.0.0.1 и ваш собственный ПК. В режиме Server CapSkip слушает ваш сетевой адрес или публичный IP, поэтому детектор, работающий на другом ПК, VPS или размещённой платформе, может обращаться к нему через тот же API. Смените хост и порт в use.js, и больше в коде ничего не меняется. Для стабильного удалённого доступа рекомендуется статический публичный IP. Режим Server всё равно работает на вашем оборудовании и всё равно без счётчика. Учтите, что режим Server открывает порт для любого адреса, поэтому добавьте в Windows Firewall блокирующее правило для адресов, которым доступ к нему не нужен. Смотрите раздел Настройки подключения с описанием переключения режимов.

FAQ

Можно ли узнать, какую капчу использует сайт, без кода?

Да. Откройте DevTools, перейдите на вкладку Network, перезагрузите страницу и посмотрите на адреса скриптов. Скрипт google.com/recaptcha или recaptcha.net означает reCAPTCHA, challenges.cloudflare.com означает Turnstile, а js.hcaptcha.com означает hCaptcha. Сниппет для Console выше делает тот же поиск за вас.

Почему detect возвращает needsBrowser, а не догадку?

Потому что неверная догадка тратит впустую целое решение и даёт токен, который сайт отклоняет без объяснений. Если в статическом HTML нет sitekey, challenge GeeTest или версии Friendly Captcha, лучше так и сказать: это быстрее, чем неудавшееся задание.

Решает ли CapSkip hCaptcha?

Да. CapSkip решает чекбоксы, невидимые виджеты и виджеты Enterprise hCaptcha через свой HTTP API с method=hcaptcha, как описано в разделе Документация API на сайте. В Node.js SDK пока нет вызова для hCaptcha, поэтому solveHcaptcha выше сама отправляет задание в in.php. FunCaptcha, Arkose и GeeTest v4 по-прежнему не поддерживаются, и детектор помечает их как unsupported, чтобы скрипт мог пропустить страницу, а не падать на ней.

Может ли размещённая платформа использовать детектор с моим CapSkip?

Да, через режим Server. Настройте CapSkip на прослушивание вашей сети или публичного IP, укажите этот хост и порт в use.js, и детектор вместе с вызовом решения работают так же, как на одном ПК. Решатель остаётся на вашем оборудовании.

Коротко

Детектор капчи на Node.js оправдывает себя тем, что запускается первым: определите тип капчи, затем решайте. Читайте скрипт и разметку в фиксированном порядке, передавайте аргументы нужному методу и считайте null, needsBrowser и unsupported ответами, а не ошибками. Пока CapSkip работает на вашей машине, счётчик не крутится, пока вы проверяете страницу. Поэтому локальный сервис распознавания капчи подходит для краулинга, который каждые несколько минут встречает новый сайт. Сам SDK для Node.js описан на странице сервиса распознавания капчи для Node.js.