Как решать капчу в Netlify Functions (Node.js SDK)

Решение капчи в Netlify Functions не может выполняться в синхронной функции, которая отвечает на запрос. Netlify останавливает такую функцию через 60 секунд и не даёт поднять этот лимит, а решение reCAPTCHA может занять несколько минут. Вместо этого решайте в фоновой функции, которой отводится 15 минут, пусть она сама отправляет форму и записывает итог в Netlify Blobs, чтобы вторая функция могла о нём сообщить. Направьте Node.js SDK от CapSkip на свой решатель в режиме Server, потому что 127.0.0.1 внутри функции Netlify указывает на машину Netlify, а не на вашу. Затем разберитесь с повторами: фоновая функция, которая выбросила исключение, запускается снова, а небрежно написанная отправит форму дважды.
Что понадобится
- CapSkip, запущенный на машине с Windows, которой управляете вы, в режиме Server, со статическим публичным IP и портом решателя, доступным из интернета. Режим Server включается в разделе Настройки подключения, а остальное разбирает шаг 1.
- Сайт на Netlify с функциями в netlify/functions, собранный на Node.js 22.12 или новее, как того требует @netlify/blobs. Netlify запускает функции на той версии Node.js, которую использует ваша сборка. Фоновые функции есть на всех тарифах с кредитами, включая Free; на устаревших тарифах условия различаются, так что проверьте свой.
- Пакет capskip версии 1.3.0 или новее, а также @netlify/blobs для записей о задачах и cheerio для чтения страницы. Внутри функции Blobs не требует настройки: Netlify сам подставляет сайт и токен.
- URL страницы с капчей. Примеры решают reCAPTCHA v2, и та же схема работает для любого типа, который поддерживает CapSkip.
# npm install capskip @netlify/blobs cheerio npm install capskip @netlify/blobs cheerio
Почему синхронная функция не справится
Netlify задаёт каждому виду функций фиксированный лимит времени выполнения, и все они перечислены в документации по настройке функций. Ни один из трёх изменить нельзя:
| Тип функции | Лимит выполнения | Её роль в этой схеме |
|---|---|---|
| Синхронная | 60 секунд | Сообщить результат задачи |
| По расписанию | 30 секунд | Запустить задачу по таймеру |
| Фоновая | 15 минут | Решить капчу, затем отправить форму |
Теперь сравните это с решением капчи в Netlify Functions. SDK ждёт ответа на reCAPTCHA до recaptchaTimeout, по умолчанию 300 секунд, а сам CapSkip позволяет заданию ждать свободного потока 250 секунд и ещё 250 тратить на решение. Решение, которое в спокойный день занимает 20 секунд, занимает 90, когда все потоки заняты или прокси работает медленно. Когда синхронная функция доходит до 60 секунд, Netlify её завершает. CapSkip об этом не знает, поэтому доводит задание до конца, и ответ так никто и не забирает.
context.waitUntil выглядит как выход, ведь он позволяет функции работать после того, как ответ уже ушёл. Но это не выход. В документации Netlify сказано, что функция всё равно может работать только до своего лимита выполнения, включая асинхронную работу, так что решение, переданное в waitUntil, точно так же умирает на 60 секундах. Потоковые ответы тоже ограничены теми же 60 секундами.
Фоновая функция сразу отвечает вызывающей стороне кодом 202 и продолжает работать до 15 минут. Расплата кроется в этом самом 202: возвращаемое значение функции никто не получает. А токен reCAPTCHA истекает примерно через две минуты после выдачи, поэтому он не может лежать и ждать, пока вызывающая сторона придёт за ним. Фоновая функция должна использовать токен сама, отправив форму, и оставить запись о том, что произошло.
Шаг 1: направляем SDK на решатель в режиме Server
Внутри функции Netlify 127.0.0.1 указывает на собственную песочницу функции. Клиент с настройками по умолчанию ни к чему там не подключится и выбросит NetworkException с ECONNREFUSED. Переключите CapSkip в режим Server, чтобы он слушал ваш публичный IP, пробросьте порт на машину с Windows, если она стоит за роутером, и разрешите этот порт в Windows Firewall.
Затем решите, кому можно подключаться. По умолчанию адреса, с которых подключаются функции Netlify, меняются по мере того, как Netlify масштабируется, поэтому правило файрвола не может их перечислить. Private Connectivity в Netlify даёт функциям фиксированный набор IP, которые можно разрешить, но это дополнение для тарифов Enterprise. Без него замком служит настройка API Key Validation в CapSkip: включите её, добавьте ключ для этого сайта и передайте его как apiKey. Каждый запрос SDK несёт ключ по обычному HTTP, как и весь остальной вызов, поэтому выдайте функции отдельный ключ, который можно удалить, ничего больше не сломав.
Храните адрес и ключ как переменные окружения в Netlify, в разделе Project configuration, затем Environment variables, с областью действия, которая включает Functions. Здесь людей подлавливают два правила Netlify. Переменные, объявленные в netlify.toml, вообще не доходят до функций. А каждый деплой сохраняет значения, заданные на момент его сборки, поэтому новый CAPSKIP_HOST ничего не меняет, пока вы не сделаете деплой заново.
import { CapSkip } from "capskip";
// The SDK does not read these by itself, so pass them in.
if (!process.env.CAPSKIP_HOST) throw new Error("CAPSKIP_HOST is not set");
const solver = new CapSkip({
apiKey: process.env.CAPSKIP_API_KEY, // a key from API Key Validation
host: process.env.CAPSKIP_HOST, // your public IP, Server mode
port: Number(process.env.CAPSKIP_PORT ?? 8080),
});Оставьте эту проверку. Если переменной нет, host будет undefined, и SDK молча откатится к значению по умолчанию 127.0.0.1, что сразу вернёт вас к ECONNREFUSED. В полном примере проверка стоит внутри блока try для решения, поэтому отсутствующая переменная попадает в запись задачи, а не останавливает функцию раньше, чем та успеет эту запись сделать.
Шаг 2: решаем и отправляем форму в фоновой функции
Чтобы сделать функцию фоновой, достаточно задать background значение true в её config. Эта функция проверяет общий секрет, потому что её URL публичный и каждый запрос к ней заставляет ваш решатель работать. Затем она загружает страницу, читает sitekey и решает капчу:
// netlify/functions/solve-signup.mjs
import { getStore } from "@netlify/blobs";
import * as cheerio from "cheerio";
// solver from Step 1; PAGE_URL is the page with the CAPTCHA.
export default async (req) => {
if (req.headers.get("x-job-secret") !== process.env.JOB_SECRET) return;
const { jobId, email } = await req.json();
const jobs = getStore({ name: "captcha-jobs", consistency: "strong" });
const page = await fetch(PAGE_URL);
const $ = cheerio.load(await page.text());
const result = await solver.recaptcha($(".g-recaptcha").attr("data-sitekey"), PAGE_URL);
// Then claim the job and post the form, below.
};
export const config = { path: "/api/solve-signup", method: "POST", background: true };Ответ для reCAPTCHA лежит в result.code. Используйте его сразу: при сроке жизни в две минуты решать и отправлять форму должен один и тот же запуск.
Теперь о повторах. Когда фоновая функция завершается ошибкой, Netlify запускает её снова через минуту, а если и это не удаётся, то ещё раз через две минуты после этого. До отправки формы повтор вам как раз нужен: свежая страница и свежее решение. После отправки формы повтор обернётся второй регистрацией. Поэтому функция захватывает задачу в Blobs, прежде чем отправить форму:
// Only one run can create this key, so only one run posts.
const claim = await jobs.setJSON(`${jobId}-posted`, { at: Date.now() }, { onlyIfNew: true });
if (!claim.modified) return;
const form = $("form").has(".g-recaptcha");
const body = new URLSearchParams(form.serializeArray().map((f) => [f.name, f.value]));
body.set("email", email);
body.set("g-recaptcha-response", result.code);
const cookie = page.headers.getSetCookie().map((c) => c.split(";")[0]).join("; ");
const res = await fetch(new URL(form.attr("action") || PAGE_URL, PAGE_URL), {
method: "POST",
headers: { cookie, referer: PAGE_URL },
body,
});
await jobs.setJSON(jobId, { state: "done", status: res.status });С onlyIfNew запись проходит, только если такого ключа ещё нет, а modified показывает, создал ли его этот запуск. Два запуска, которые наперегонки берутся за одну задачу, не могут оба получить true, поэтому форму отправит только один из них. Кроме того, функция проверяет этот ключ до начала работы, так что повторный запуск уже завершённой задачи возвращается, не тратя решение.
Сама отправка передаёт cookies страницы и собственные поля формы, включая скрытый CSRF-токен, а именно их, помимо капчи, проверяет большинство форм регистрации. Она также передаёт страницу как Referer, потому что fetch его не отправляет, а некоторые фреймворки отклоняют отправку формы по HTTPS без него. Как только задача захвачена, записывайте любую ошибку в Blobs, а не выбрасывайте исключение. Повторный запуск всё равно остановится на захвате, так что исключение ничего не даёт, а запись остаётся единственным местом, где ваш эндпоинт статуса может показать ошибку. Полный пример ниже так и оборачивает обе фазы.
Шаг 3: запускаем задачи и читаем их результаты
Запускайте задачу из любого серверного кода, отправив POST-запрос фоновой функции. Идентификатор задачи придумывает вызывающая сторона, потому что у ответа 202 нет тела, в котором его можно было бы вернуть:
const jobId = crypto.randomUUID();
const start = await fetch("https://YOUR_SITE.netlify.app/api/solve-signup", {
method: "POST",
headers: { "x-job-secret": process.env.JOB_SECRET },
body: JSON.stringify({ jobId, email: "YOUR_EMAIL" }),
});
console.log(start.status); // 202: accepted, not solved yetО записи сообщает небольшая синхронная функция. Она отрабатывает за миллисекунды, с огромным запасом до лимита в 60 секунд:
// netlify/functions/job-status.mjs
import { getStore } from "@netlify/blobs";
export default async (req, context) => {
const jobs = getStore({ name: "captcha-jobs", consistency: "strong" });
const job = await jobs.get(context.params.id, { type: "json" });
if (!job) return new Response("unknown or not started", { status: 404 });
return Response.json(job);
};
export const config = { path: "/api/jobs/:id", method: "GET" };Опрашивайте её каждые несколько секунд. Записи у задачи нет, пока она не завершится или пока одна из попыток не упадёт с ошибкой, поэтому 404 означает, что её первая попытка ещё выполняется или что задача вообще не запустилась, потому что секрет не совпал. Читайте со строгой согласованностью, как здесь. По умолчанию Blobs обеспечивает только согласованность в конечном счёте: новая запись появляется сразу, но обновление может доходить до всех edge-узлов до 60 секунд, а этого достаточно, чтобы уже завершённая задача отображалась как всё ещё повторяющаяся.
Чтобы запускать задачу по таймеру, возьмите функцию по расписанию, но помните о её лимите: 30 секунд, вдвое меньше, чем у синхронной. Пусть она передаёт работу фоновой функции и укладывается в доли секунды:
// netlify/functions/nightly-signup.mjs
export default async () => {
const res = await fetch(`${process.env.URL}/api/solve-signup`, {
method: "POST",
headers: { "x-job-secret": process.env.JOB_SECRET },
body: JSON.stringify({ jobId: crypto.randomUUID(), email: "YOUR_EMAIL" }),
});
console.log("queued:", res.status); // 202 means accepted, not solved
};
export const config = { schedule: "@daily" };URL входит в число переменных только для чтения, которые Netlify передаёт функциям во время выполнения: это основной адрес вашего сайта. Функции по расписанию срабатывают только на опубликованных деплоях, а не на Deploy Previews или деплоях веток.
Полный рабочий пример
// npm install capskip @netlify/blobs cheerio
// netlify/functions/solve-signup.mjs
import { getStore } from "@netlify/blobs";
import * as cheerio from "cheerio";
import { CapSkip } from "capskip";
const PAGE_URL = "https://example.com/signup";
export default async (req) => {
// Anyone can POST to this URL, so check a shared secret first.
if (req.headers.get("x-job-secret") !== process.env.JOB_SECRET) return;
const { jobId, email } = await req.json();
const jobs = getStore({ name: "captcha-jobs", consistency: "strong" });
if (await jobs.get(`${jobId}-posted`)) return; // already posted once
// Phase 1: fetch and solve. Throwing here is safe: Netlify runs
// the function again after one minute, then two minutes later.
let page, $, result;
try {
if (!process.env.CAPSKIP_HOST) throw new Error("CAPSKIP_HOST is not set");
const solver = new CapSkip({
apiKey: process.env.CAPSKIP_API_KEY,
host: process.env.CAPSKIP_HOST,
port: Number(process.env.CAPSKIP_PORT ?? 8080),
});
page = await fetch(PAGE_URL);
$ = cheerio.load(await page.text());
result = await solver.recaptcha($(".g-recaptcha").attr("data-sitekey"), PAGE_URL);
} catch (err) {
const prev = await jobs.get(jobId, { type: "json" });
const attempt = (prev?.attempt ?? 0) + 1;
// The first run plus two retries: after the third, nothing reruns.
const state = attempt < 3 ? "retrying" : "failed";
await jobs.setJSON(jobId, { state, attempt, error: String(err) });
throw err;
}
// Phase 2: post the form once. Claim the job first, so a rerun
// that reaches this line finds the claim taken and stops.
const claim = await jobs.setJSON(`${jobId}-posted`, { at: Date.now() }, { onlyIfNew: true });
if (!claim.modified) return;
try {
const form = $("form").has(".g-recaptcha");
const body = new URLSearchParams(form.serializeArray().map((f) => [f.name, f.value]));
body.set("email", email);
body.set("g-recaptcha-response", result.code);
const cookie = page.headers.getSetCookie().map((c) => c.split(";")[0]).join("; ");
const res = await fetch(new URL(form.attr("action") || PAGE_URL, PAGE_URL), {
method: "POST",
headers: { cookie, referer: PAGE_URL },
body,
});
await jobs.setJSON(jobId, { state: "done", status: res.status });
} catch (err) {
// No rethrow: a retry could not post again, so record it here.
await jobs.setJSON(jobId, { state: "failed", error: String(err) });
}
};
export const config = { path: "/api/solve-signup", method: "POST", background: true };Задайте JOB_SECRET, CAPSKIP_HOST, CAPSKIP_API_KEY и, если вы его меняли, CAPSKIP_PORT в Netlify UI, сделайте деплой и запустите задачу, как в шаге 3. Все опции, которые принимает вызов reCAPTCHA, включая invisible и Enterprise, работают здесь без изменений; на странице сервиса распознавания reCAPTCHA v2 описано, что нужно этому типу.
Частые ошибки и что они означают
| Что вы видите | Причина | Исправить |
|---|---|---|
| Запрос падает примерно через минуту, и форма не отправляется | Решение выполняется в синхронной функции или внутри waitUntil, у которого тот же лимит в 60 секунд | Перенесите решение в фоновую функцию |
| Запись задачи сообщает, что CAPSKIP_HOST не задан, или NetworkException с ECONNREFUSED 127.0.0.1:8080, если вы убрали эту проверку | В задеплоенной функции нет CAPSKIP_HOST, поэтому SDK откатился бы к loopback | Задайте переменную с областью действия, которая включает Functions, и сделайте деплой заново |
| Работает с netlify dev, ломается после деплоя | Локально функция работает на вашей собственной машине, где loopback и ваш локальный IP доходят до решателя | Используйте режим Server и свой публичный IP для задеплоенного сайта |
| Переменная из netlify.toml в функции равна undefined | Переменные, объявленные в netlify.toml, не доходят до функций | Задайте её в Netlify UI, CLI или API |
| Функция всё ещё использует старый CAPSKIP_HOST | Деплой сохраняет значения, заданные на момент его сборки | Сделайте деплой заново после изменения переменной |
| Соединение висит, а затем NetworkException с ETIMEDOUT | Порт не проброшен, или Windows Firewall его блокирует | Пробросьте порт, разрешите его в Windows Firewall и проверьте его снаружи своей сети |
| ApiException с ERROR_KEY_DOES_NOT_EXIST | API Key Validation включена, а ключа нет в списке CapSkip, или CAPSKIP_API_KEY не задан, и SDK отправил своё значение по умолчанию | Добавьте ключ в CapSkip и задайте переменную |
| Форма отправлена дважды | Ошибка после отправки вызвала повторный запуск, и ничто не остановило второй прогон | Захватите задачу через onlyIfNew до отправки, как в шаге 2 |
| Эндпоинт статуса бесконечно возвращает 404 | Секрет не совпал, поэтому функция вернулась, ничего не записав | Задайте один и тот же JOB_SECRET у вызывающей стороны и на сайте |
| Сайт отклоняет токен | Он был старше примерно двух минут или уже использован | Отправляйте сразу после решения, один токен на одну отправку |
FAQ
Можно ли поднять лимит в 60 секунд для решения капчи в Netlify Functions?
Нет. Netlify указывает лимиты синхронных функций, функций по расписанию и фоновых функций как фиксированные, а waitUntil и потоковые ответы остаются в пределах тех же 60 секунд. Самый длинный из доступных лимитов составляет 15 минут у фоновой функции, и он с большим запасом покрывает 300 секунд ожидания SDK.
Может ли функция Netlify дотянуться до CapSkip на моём домашнем или офисном ПК?
Да, через режим Server. CapSkip слушает ваш публичный IP, роутер пробрасывает порт на этот ПК, а функция подключается через тот же HTTP API, который использовала бы локально. Статический публичный IP сохраняет CAPSKIP_HOST актуальным между деплоями. Без Private Connectivity разрешить Netlify по адресу нельзя, поэтому роль привратника выполняет API Key Validation.
Берёт ли CapSkip плату за каждое решение, когда его вызывает Netlify?
Нет. Режим Server меняет то, откуда доступен решатель, а не то, кто его запускает: это по-прежнему ваша собственная машина с Windows, и решения она не считает. Netlify тарифицирует время работы функций, поэтому фоновая функция, которая две минуты ждёт решения, расходует две минуты этого времени.
Чем это отличается от запуска в AWS Lambda?
Ограничения другие. В Lambda за API Gateway стеной служит таймаут интеграции в 29 секунд, а NAT gateway с Elastic IP даёт каждому решению один фиксированный исходящий адрес для вашего файрвола. В Netlify стена стоит на 60 секундах, встроенный способ её обойти даёт фоновая функция, а фиксированного адреса нет, если не считать дополнения для Enterprise. Настройка для Lambda описана в руководстве по решению капчи в AWS Lambda.
Коротко
Когда решаете капчу в Netlify Functions, никогда не делайте этого в синхронной функции: её лимит в 60 секунд фиксирован, и waitUntil его не обходит. Решайте в фоновой функции, отправляйте форму в том же запуске, пока токен свежий, и сначала захватывайте задачу записью с onlyIfNew, чтобы два повтора Netlify никогда не отправили форму дважды. Сообщайте результаты из Blobs через небольшую синхронную функцию. Подключайтесь к CapSkip в режиме Server, храните адрес и ключ в переменных с областью действия Functions и включите API Key Validation.
- Все остальные типы капчи, которые решает пакет для Node, описаны на странице сервиса распознавания капчи для Node.js.
Netlify предоставляет функции, а само решение остаётся на машине с Windows, которой владеете вы. Вот зачем запускать собственное распознавание капчи: счётчик платформы считает минуты, а решения не считает никто.
