Как решить Friendly Captcha в Node.js без браузера

Чтобы решить Friendly Captcha в Node.js, загрузите страницу и сохраните cookies, которые она устанавливает, прочитайте виджет frc-captcha и его тег script с помощью cheerio, определите по этому скрипту v1 или v2 и передайте sitekey, URL страницы, версию и адрес скрипта в метод friendlyCaptcha из CapSkip. Отправьте возвращённый им токен вместе с собственными скрытыми полями формы: в frc-captcha-response для v2 или в frc-captcha-solution для v1. Большинство сбоев здесь вызывают две вещи. Встроенный fetch не хранит cookies, поэтому форма с защитой от CSRF отклоняет даже идеальный токен. А если решить не ту версию, вернётся токен, который выглядит корректным, но молча не проходит проверку. CapSkip добавил Friendly Captcha в версии 1.4.0, и в этом руководстве весь процесс проходит без браузера на вашей стороне.
Что понадобится
- CapSkip 1.4.0 или новее на машине с Windows. Именно в этом выпуске появилась Friendly Captcha, а вместе с ней CaptchaFox и Capy Puzzle.
- Node.js 22 или новее и пакет capskip версии 1.3.0 или новее: это первый выпуск, в котором есть friendlyCaptcha. В примерах также используется cheerio, чтобы читать HTML.
- URL страницы, на которой показан виджет. Sitekey, адрес скрипта и имя поля берутся из HTML этой страницы.
- Адрес решателя. Режим Local отвечает на 127.0.0.1 и только для этого устройства; режим Server слушает ваш сетевой адрес или публичный IP, чтобы скрипт на другой машине мог обращаться к нему через API. Оба режима задаются в разделе Настройки подключения, а когда переключаться, объясняет шаг 4.
# npm install capskip cheerio npm install capskip cheerio
Примеры написаны как ES-модули с await верхнего уровня. Сохраняйте их с расширением .mjs или поменяйте значение поля type в своём package.json на module, потому что npm init теперь записывает туда commonjs, и тогда import работает, как показано. Если ваш проект на CommonJS, пакет capskip загружается и через require.
Шаг 1: загружаем страницу и сохраняем её cookies
Встроенный в Node fetch хорош как HTTP-клиент, но у него есть один пробел, который здесь важен: у него нет хранилища cookies. Каждый вызов начинается с чистого листа, поэтому сессионная cookie, которую устанавливает страница, пропадает к тому моменту, когда вы отправляете форму. Сайт, который привязывает свой CSRF-токен к этой сессии, отклоняет такую отправку, часто с 403, каким бы хорошим ни был токен капчи. В Python эту проблему скрывает requests.Session, а в Node cookies приходится переносить самостоятельно.
Для этого служит метод Headers.getSetCookie(), который возвращает каждую строку Set-Cookie по отдельности. Обычный вызов headers.get с этой задачей не справится, потому что он склеивает строки через запятую, а даты истечения cookies тоже содержат запятые.
// npm install capskip cheerio
import * as cheerio from "cheerio";
const PAGE_URL = "https://example.com/signup";
// fetch() keeps no cookies between calls, so carry them by hand.
const jar = new Map();
function remember(res) {
for (const line of res.headers.getSetCookie()) {
const pair = line.split(";")[0];
const eq = pair.indexOf("=");
jar.set(pair.slice(0, eq).trim(), pair.slice(eq + 1).trim());
}
}
const cookieHeader = () => [...jar].map(([k, v]) => `${k}=${v}`).join("; ");
const page = await fetch(PAGE_URL);
remember(page);
const $ = cheerio.load(await page.text());Это хранилище намеренно сделано маленьким. Оно хранит имена и значения и игнорирует пути и сроки действия, а больше одному сайту и одной форме и не нужно.
Шаг 2: читаем виджет, выбираем v1 или v2 и вызываем friendlyCaptcha
Обе версии выводят один и тот же элемент: div с классом frc-captcha и атрибутом data-sitekey, поэтому сам виджет ничего не говорит о протоколе. Зато об этом говорит тег script. Сайт на v2 загружает пакет @friendlycaptcha/sdk, файл которого называется site.min.js, а сайт на v1 загружает friendly-challenge, файл которого называется widget.module.min.js. Cheerio достаёт и то и другое в несколько строк:
const widget = $(".frc-captcha").first();
const form = widget.closest("form");
// Every script except nomodule fallbacks, as full addresses.
const scripts = $("script[src]").not("[nomodule]")
.map((_, el) => new URL($(el).attr("src"), PAGE_URL).href)
.get();Передавать URL страницы в качестве базы важно для виджетов, которые сайт хостит у себя. Так относительный src вроде /vendor/v2/site.min.js превращается в полный адрес, а для v2 CapSkip загружает именно этот скрипт в своём браузере, чтобы выполнить решение. Голый путь не загрузится никогда.
CapSkip выбирает версию в фиксированном порядке и останавливается на первом ответе: сначала версия, которую вы передали, затем адрес скрипта, переданный как moduleScript, затем значение по умолчанию v1. Ловушка кроется именно в значении по умолчанию. Собранный скрипт вроде /assets/app.4f2a.js ничего не говорит CapSkip, поэтому он решает v1, и сайт на v2 отклоняет каждый токен. Вместо этого определяйте версию в своём коде, где можно отказаться гадать:
const V2_FILES = ["site.min.js", "site.compat.min.js"];
const V1_FILES = ["widget.module.min.js", "widget.min.js", "widget.polyfilled.min.js"];
function friendlyVersion(scripts, widget) {
// Package names are unambiguous, so check them first.
for (const src of scripts) {
if (src.includes("@friendlycaptcha/sdk")) return { version: "v2", script: src };
if (src.includes("friendly-challenge")) return { version: "v1", script: src };
}
// Self-hosted builds usually keep the file name. Themes ship their own
// site.min.js too, so only trust a path that says friendly.
for (const src of scripts.filter((s) => s.toLowerCase().includes("friendly"))) {
const file = new URL(src).pathname.split("/").pop();
if (V2_FILES.includes(file)) return { version: "v2", script: src };
if (V1_FILES.includes(file)) return { version: "v1", script: src };
}
// Last resort: v2 and v1 name their widget options differently.
if (widget.is("[data-api-endpoint], [data-form-field-name]")) return { version: "v2" };
if (widget.is("[data-puzzle-endpoint], [data-solution-field-name]")) return { version: "v1" };
throw new Error("v1 or v2? Read the page and set it by hand.");
}Проверка по имени файла доверяет только путям, в которых упоминается friendly, потому что тема сайта может загружать собственный site.min.js, не имеющий никакого отношения к виджету.
Затем сделайте вызов. friendlyCaptcha принимает sitekey, URL страницы и объект опций. SDK отбрасывает значения undefined ещё до отправки, так что атрибут, которого на странице нет, просто не попадает в запрос:
import { CapSkip } from "capskip";
const solver = new CapSkip({ host: "127.0.0.1", port: 8080 });
const { version, script } = friendlyVersion(scripts, widget);
const result = await solver.friendlyCaptcha(widget.attr("data-sitekey"), PAGE_URL, {
version,
moduleScript: script,
// EU sites: data-api-endpoint="eu" (v2) or data-puzzle-endpoint (v1)
apiServer: widget.attr("data-api-endpoint") ?? widget.attr("data-puzzle-endpoint"),
});
console.log(result.token.slice(0, 40)); // v2 tokens start with AQQA.Строка с apiServer нужна для сайтов на эндпоинте EU сервиса Friendly Captcha. Глобальный эндпоинт всё равно выдаёт токен для sitekey из EU, поэтому решение там проваливается только на собственной проверке сайта: это тот же тихий сбой, что и с неверной версией. result.token содержит строку для отправки, result.code хранит ту же строку, а result.captchaId служит идентификатором задания в CapSkip. Версия, отличная от v1, v2, 1 или 2, пустой sitekey или опция, которую метод не знает, вызывают ValidationException ещё до того, как запрос покинет вашу машину.
Шаг 3: отправляем токен вместе с собственными полями формы
Поля для токена нет в HTML, который вы загрузили, потому что его создаёт скрипт виджета в браузере. Поэтому добавьте его сами, под тем именем, которое используют версия и виджет. Всё остальное, что несёт форма, включая скрытый CSRF-токен, берётся из самой формы:
// A renamed field wins; otherwise the default for the version.
const field = widget.attr("data-form-field-name")
?? widget.attr("data-solution-field-name")
?? (version === "v2" ? "frc-captcha-response" : "frc-captcha-solution");
// The form's own fields, hidden CSRF token included.
const body = new URLSearchParams(
form.serializeArray().map((f) => [f.name, f.value]),
);
body.set("email", "YOUR_EMAIL");
body.set(field, result.token);
const res = await fetch(new URL(form.attr("action") || PAGE_URL, PAGE_URL), {
method: "POST",
// Browsers send the page as Referer; some servers refuse a post without it.
headers: { cookie: cookieHeader(), referer: PAGE_URL },
body,
});
console.log(res.status);serializeArray собирает поля, которые отправил бы браузер, и именно так CSRF-токен возвращается на сервер, хотя вы его нигде не называете. Кнопку отправки он не включает, поэтому если сайт проверяет имя кнопки, добавьте его через body.set. Передавайте URL страницы и как Referer: fetch не отправляет ни заголовок Referer, ни Origin, а некоторые фреймворки, в том числе Django, отклоняют отправку формы по HTTPS, в которой нет ни того ни другого, какими бы правильными ни были cookie и токен. С телом URLSearchParams fetch отправляет обычную форму и сам выставляет тип содержимого. Токен v2 весит примерно шесть килобайт, поэтому ему место в этом теле и никогда в строке запроса, а токен v1 состоит из четырёх частей, разделённых точками, и занимает несколько сотен символов. Передавайте его без изменений.
В двух случаях придётся заглянуть в DevTools. В v1 атрибут data-solution-field-name, равный одному дефису, означает, что виджет вообще не пишет поле, а собственный скрипт сайта отправляет токен другим способом. Кроме того, некоторые сайты отправляют JSON из JavaScript, а не отправляют форму. В обоих случаях отправьте форму один раз вручную и скопируйте запрос, который на самом деле делает страница.
Каждый токен годится для одной отправки. Проверка Friendly Captcha отклоняет ответ, который уже был использован или истёк, поэтому решайте заново для каждой формы.
Шаг 4: много форм одновременно и где работает решатель
Каждый метод CapSkip в Node уже возвращает Promise, а AsyncCapSkip служит просто другим именем того же класса, так что параллельность здесь реализуется обычным JavaScript. Нужен лишь потолок. Прогоните сотню вызовов через Promise.all, и CapSkip поставит в очередь всё, что выходит за его настройку Max. Threads для Friendly Captcha (по умолчанию 10), а там задание ждёт свободного потока до 250 секунд, прежде чем CapSkip признает его неудачным. Небольшой пул воркеров держит в работе не больше десяти вызовов одновременно:
// Run fn over items with at most `limit` in flight. A failed item
// becomes its Error instead of rejecting the whole batch.
async function mapLimited(items, limit, fn) {
const results = new Array(items.length);
let next = 0;
async function worker() {
while (next < items.length) {
const i = next++;
results[i] = await fn(items[i]).catch((err) => err);
}
}
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
return results;
}
// jobs: { sitekey, url, version, script, apiServer } objects from Step 2
const tokens = await mapLimited(jobs, 10, (job) =>
solver.friendlyCaptcha(job.sitekey, job.url, {
version: job.version,
moduleScript: job.script,
apiServer: job.apiServer,
}).then((r) => r.token));Будьте готовы к тому, что время решения будет плавать. Friendly Captcha задаёт объём работы для каждого запроса и повышает его для адресов, которые уже видела много раз, а CapSkip решает каждый виджет v2 в настоящем браузере. Поэтому метод опрашивает результат до recaptchaTimeout, по умолчанию 300 секунд, а не до defaultTimeout в 120 секунд. У CapSkip есть и собственные часы: в его настройках Friendly Captcha решению отводится 120 секунд Row Timeout, после чего оно признаётся неудачным. Чтобы изменить ожидание SDK для одного вызова, передайте timeout в опциях. SDK должен пережить ожидание потока плюс само решение, а при настройках CapSkip по умолчанию это может доходить до 370 секунд. Так что держите пул на уровне Max. Threads или передайте более длинный timeout и поднимайте его снова каждый раз, когда поднимаете Row Timeout, иначе вызов закончится TimeoutException, пока CapSkip ещё работает. Интервал опроса тоже можно задать для отдельного вызова, но только как polling_interval в snake case. Вариант pollingInterval в camelCase относится к конструктору, а как опция отдельного вызова приводит к ValidationException. Если на долгом прогоне решения замедляются, обычно причина кроется в растущей сложности для одного адреса, поэтому передавайте объект прокси с ключами type и uri или настройте пул прокси в CapSkip.
В примерах стоит 127.0.0.1, потому что это верно, пока Node и решатель делят одну машину. Запустите скрипт где-то ещё, например на VPS, в контейнере или на раннере CI, и локальная петля будет указывать на саму эту машину, поэтому первый же вызов выбросит NetworkException с ECONNREFUSED. Переключите CapSkip в режим Server, и он будет слушать ваш сетевой адрес или публичный IP, так что любой из этих вариантов сможет обратиться к нему через тот же API. Если маршрут идёт через интернет, используйте статический публичный IP с правилом файрвола для ожидаемых адресов. Это по-прежнему ваше собственное железо, и по-прежнему без платы за каждое решение. Клиент сам не читает переменные окружения, поэтому читайте CAPSKIP_HOST в своём коде и передавайте его клиенту, как это делает полный пример ниже.
Полный рабочий пример
Вот весь процесс в одном файле: самый короткий полный способ решить Friendly Captcha в Node.js, имея на входе только URL страницы.
// npm install capskip cheerio
// solve-friendly.mjs: run with node solve-friendly.mjs
import * as cheerio from "cheerio";
import { CapSkip, CapSkipError, ValidationException } from "capskip";
const PAGE_URL = "https://example.com/signup";
const V2_FILES = ["site.min.js", "site.compat.min.js"];
const V1_FILES = ["widget.module.min.js", "widget.min.js", "widget.polyfilled.min.js"];
const jar = new Map();
function remember(res) {
for (const line of res.headers.getSetCookie()) {
const pair = line.split(";")[0];
const eq = pair.indexOf("=");
jar.set(pair.slice(0, eq).trim(), pair.slice(eq + 1).trim());
}
}
const cookieHeader = () => [...jar].map(([k, v]) => `${k}=${v}`).join("; ");
function friendlyVersion(scripts, widget) {
for (const src of scripts) {
if (src.includes("@friendlycaptcha/sdk")) return { version: "v2", script: src };
if (src.includes("friendly-challenge")) return { version: "v1", script: src };
}
for (const src of scripts.filter((s) => s.toLowerCase().includes("friendly"))) {
const file = new URL(src).pathname.split("/").pop();
if (V2_FILES.includes(file)) return { version: "v2", script: src };
if (V1_FILES.includes(file)) return { version: "v1", script: src };
}
if (widget.is("[data-api-endpoint], [data-form-field-name]")) return { version: "v2" };
if (widget.is("[data-puzzle-endpoint], [data-solution-field-name]")) return { version: "v1" };
throw new Error("v1 or v2? Read the page and set it by hand.");
}
const solver = new CapSkip({
apiKey: process.env.CAPSKIP_API_KEY ?? "capskip",
host: process.env.CAPSKIP_HOST ?? "127.0.0.1",
port: Number(process.env.CAPSKIP_PORT ?? 8080),
});
const page = await fetch(PAGE_URL);
remember(page);
const $ = cheerio.load(await page.text());
const widget = $(".frc-captcha").first();
if (!widget.attr("data-sitekey")) {
throw new Error("No frc-captcha widget in the HTML; it may be built by JS.");
}
const form = widget.closest("form");
const scripts = $("script[src]").not("[nomodule]")
.map((_, el) => new URL($(el).attr("src"), PAGE_URL).href).get();
const { version, script } = friendlyVersion(scripts, widget);
let result;
try {
result = await solver.friendlyCaptcha(widget.attr("data-sitekey"), PAGE_URL, {
version,
moduleScript: script,
apiServer: widget.attr("data-api-endpoint") ?? widget.attr("data-puzzle-endpoint"),
});
} catch (err) {
if (err instanceof ValidationException) throw new Error(`not sent: ${err.message}`);
if (err instanceof CapSkipError) throw new Error(`solve failed: ${err.name}: ${err.message}`);
throw err;
}
const field = widget.attr("data-form-field-name")
?? widget.attr("data-solution-field-name")
?? (version === "v2" ? "frc-captcha-response" : "frc-captcha-solution");
// Post wherever the form's action points, with its other fields.
const body = new URLSearchParams(form.serializeArray().map((f) => [f.name, f.value]));
body.set("email", "YOUR_EMAIL");
body.set(field, result.token);
const res = await fetch(new URL(form.attr("action") || PAGE_URL, PAGE_URL), {
method: "POST",
// Browsers send the page as Referer; some servers refuse a post without it.
headers: { cookie: cookieHeader(), referer: PAGE_URL },
body,
});
console.log(res.status, version, field);Версия определяется один раз и используется дважды: для решения и для имени поля, поэтому они никогда не разойдутся. Если cheerio не находит виджет, обычно это значит, что страница строит его из JavaScript, и sitekey нужно брать с отрендеренной страницы или из скрипта, который его создаёт. Все параметры, которые принимает сырой эндпоинт, описаны в справочнике API Friendly Captcha.
Частые ошибки и что они означают
| Что вы видите | Причина | Исправить |
|---|---|---|
| 403 или ошибка CSRF при токене, который вернул CapSkip | Запрос ушёл без cookies страницы, её скрытых полей или Referer | Отправьте заголовок cookie из шага 1 и Referer, а тело соберите через serializeArray |
| Сайт отклоняет токен без какой-либо другой ошибки | Решена не та версия, часто v1 по умолчанию | Определите версию в коде, как в шаге 2, и передайте её |
| Отклоняется на сайте, где у виджета есть data-api-endpoint (v2) или data-puzzle-endpoint (v1) | Токен пришёл с глобального эндпоинта, а сайт использует эндпоинт EU | Передайте значение атрибута как apiServer |
| Решение v2 не проходит на сайте, который хостит виджет у себя | moduleScript был относительным путём, поэтому виджет так и не загрузился | Разрешите src относительно URL страницы через new URL |
| ValidationException с упоминанием pollingInterval | Опция для отдельного вызова пишется как polling_interval | Используйте snake case в вызове или задайте pollingInterval в конструкторе |
| SyntaxError из-за await или import | Файл выполняется как CommonJS | Используйте расширение .mjs или укажите module в поле type пакета |
| TypeError: getSetCookie is not a function | Node.js версии ниже 18.15 или 19.7 | Обновитесь до Node.js 22 или новее |
| ApiException с ERROR_CAPTCHA_UNSOLVABLE через несколько секунд, каждый раз | Friendly Captcha отклоняет sitekey или origin страницы, либо в аккаунте не включены v2 или эндпоинт EU | Проверьте sitekey, URL страницы и apiServer; повторная попытка не поможет |
| ApiException с ERROR_CAPTCHA_UNSOLVABLE через две минуты или позже | Решение вышло за Row Timeout в CapSkip (120 секунд) или ждало свободного потока дольше Wait Timeout (250 секунд) | Держите пул на уровне Max. Threads, добавьте прокси или поднимите Row Timeout в настройках Friendly Captcha |
| TimeoutException через 300 секунд | Задание ждало свободного потока, а затем решалось, и вместе это длилось дольше, чем SDK опрашивает результат | Держите пул на уровне Max. Threads или передайте более длинный timeout |
| NetworkException с ECONNREFUSED | CapSkip не запущен либо неверны хост и порт | Запустите CapSkip, затем проверьте, какой режим нужен: Local или Server |
FAQ
Нужен ли Puppeteer или Playwright, чтобы решить Friendly Captcha в Node.js?
Нет. Вашему скрипту нужен только HTML, а его получает fetch. Работа с браузером происходит внутри CapSkip: виджеты v2 он решает в своём браузере, а головоломки v1 напрямую. Если ваш скрипт и так управляет браузером по другим причинам, тот же вызов тоже работает: просто читайте sitekey и адрес скрипта с живой страницы, а не из загруженного HTML.
Работает ли это в TypeScript?
Да. Пакет capskip поставляется с собственными определениями типов, а объект опций типизирован как с именами в camelCase, которые используются здесь, так и с именами API в snake_case. Есть одна загвоздка: общий тип результата объявляет token необязательным и всё ещё описывает его как поле ALTCHA, потому что одна форма результата покрывает все методы. friendlyCaptcha всегда его заполняет, поэтому non-null assertion для result.token безопасен. attr в cheerio тоже возвращает string или undefined, так что в режиме strict точно так же применяйте assertion к sitekey и src скрипта.
Может ли приложение на Node на VPS использовать решатель на моём ПК с Windows?
Да. Переведите CapSkip в режим Server в настройках подключения, чтобы он слушал сетевой адрес, а не локальную петлю, задайте CAPSKIP_HOST там, где работает приложение, и передайте его клиенту, как это делает полный пример. VPS, контейнер и хостинговый раннер подключаются через один и тот же HTTP API. Если маршрут идёт через интернет, используйте статический публичный IP с правилом файрвола. Решатель остаётся на вашем собственном железе, поэтому рост числа решений никак не меняет ваши расходы.
Чем это отличается от руководства по Python?
Эндпоинт, опции и результат одинаковы во всех SDK CapSkip; различается код вокруг них. В Python requests.Session хранит cookies за вас, а fetch в Node нужно небольшое хранилище из шага 1. AsyncCapSkip в Python представляет собой отдельный асинхронный клиент, а в Node каждый метод и так асинхронный, поэтому добавить нужно только ограничение на то, сколько вызовов выполняется одновременно. Версия этого процесса для Python описана в руководстве по Friendly Captcha для Python.
Коротко
Чтобы решить Friendly Captcha в Node.js, загрузите страницу и сохраните её cookies через getSetCookie, затем прочитайте элемент frc-captcha и его теги script с помощью cheerio, превратив каждый src в полный адрес. Определите v1 или v2 по имени пакета или файла либо по собственным атрибутам виджета, а если ни то ни другое не подсказывает, выбросьте исключение. Вызовите friendlyCaptcha с sitekey, URL страницы, этой версией и адресом скрипта, а для сайтов из EU добавьте apiServer. Отправьте токен один раз, в теле запроса, рядом с собственными полями формы и в поле, имя которого задаёт версия.
- Как работает этот тип и что покрывает решатель: страница решения Friendly Captcha.
- Все остальные типы капчи, которые решает пакет для Node, описаны на странице сервиса распознавания капчи для Node.js.
Friendly Captcha берёт плату за сложность процессорным временем, а не деньгами, поэтому загруженный день обходится лишь временем решения на вашей собственной машине. Запустите локальный сервис распознавания капчи у себя, и ограничивать вас будут только эта машина и потоки, которые вы ей выделите.
