Как решить капчу внутри Apify Actor (Node.js)

apify captcha - How to Solve CAPTCHA Inside an Apify Actor (Node.js)

Шаг с капчей в Apify состоит из привычных трёх действий плюс одного, специфичного для платформы. Считать sitekey, решить его, отправить токен вместе с формой. Дополнительное действие состоит в том, чтобы решить, где живёт решатель, потому что Actor работает не на вашем ноутбуке. Он работает в контейнере на инфраструктуре Apify, поэтому адрес обратной петли внутри него принадлежит этому контейнеру и никому больше. Примите это единственное решение правильно, и всё остальное укладывается в двадцать строк.

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

  • Node.js 18 или новее, Apify CLI и аккаунт Apify.
  • Установленные в Actor пакеты apify и capskip.
  • Запущенный CapSkip в режиме Server на машине, доступной для Actor, со статическим публичным IP и включённой проверкой ключа. Режим Local по-прежнему работает, пока вы разрабатываете на своей машине. Оба режима описаны в разделе Настройки подключения.
# Scaffold an Actor, then add the solver client.
apify create captcha-actor -t getting_started_node
cd captcha-actor
npm install capskip

Здесь без режима Server не обойтись

Именно на этом чаще всего спотыкаются, поэтому начнём с него. Actor выполняется на воркерах Apify. Когда код вашего Actor открывает соединение к 127.0.0.1:8080, он обращается к собственному контейнеру, в котором решателя нет, и вызов падает с ошибкой соединения, будто решатель упал. Ничего не падало. Просто адрес был локальным для не той машины.

Именно для этого у CapSkip два режима подключения. Режим Local привязывается к 127.0.0.1 и отвечает только этому устройству. Режим Server привязывается к вашему сетевому или публичному IP, поэтому другая машина, VPS или облачная платформа вроде Apify могут обращаться к тому же решателю через API. Статический публичный IP держит адрес постоянным от запуска к запуску.

Стоит сказать прямо, потому что вопрос возникает: режим Server не превращает CapSkip в тарифицируемый облачный сервис. Это по-прежнему ваша машина и по-прежнему безлимит. Меняется только то, какой интерфейс он слушает. Как только он начал слушать сетевой адрес, включите проверку ключа и выдайте Actor собственный ключ, чтобы этот ключ можно было отозвать, не задев остальное.

Шаг 1: объявляем адрес решателя секретным входным параметром

Не зашивайте хост в код. Схема входных данных в Apify поддерживает зашифрованные поля, и это правильное место для адреса решателя и его ключа: значения задаются на каждый запуск, а не запекаются в сборку. Шифрование работает с редакторами textfield, textarea и hidden.

{
  "title": "CAPTCHA actor input",
  "type": "object",
  "schemaVersion": 1,
  "properties": {
    "targetUrl": {
      "title": "Target URL",
      "type": "string",
      "editor": "textfield"
    },
    "solverHost": {
      "title": "Solver host",
      "type": "string",
      "editor": "textfield",
      "isSecret": true
    },
    "solverKey": {
      "title": "Solver API key",
      "type": "string",
      "editor": "textfield",
      "isSecret": true
    }
  },
  "required": ["targetUrl", "solverHost"]
}

Этот файл кладётся в папку .actor рядом с actor.json, а значения приходят в ваш код через объект входных данных.

Шаг 2: выбираем хост во время выполнения

Вам нужен один Actor, который работает в обоих местах: обращается к 127.0.0.1, пока вы запускаете его локально, и к вашему серверу после развёртывания. В SDK ровно для этого есть булево значение. Actor.isAtHome() возвращает true, когда код выполняется на платформе Apify, и false, когда нет.

// npm install apify capskip
import { Actor } from 'apify';
import { CapSkip } from 'capskip';

await Actor.init();
const input = await Actor.getInput();

// Local run talks to the loopback address. A platform run
// talks to the server address that came in as a secret.
const solver = new CapSkip({
  host: Actor.isAtHome() ? input.solverHost : '127.0.0.1',
  port: 8080,
  apiKey: input.solverKey,
});

Больше ничему в Actor знать об этой разнице не нужно. Один и тот же путь в коде обслуживает оба случая, и неудачное развёртывание больше не означает правку констант.

Шаг 3: считываем sitekey, решаем капчу, отправляем токен

Sitekey сидит в основном документе как атрибут data-sitekey, поэтому обычный fetch и регулярное выражение достают его без запуска браузера. На платной платформе это важно: Actor без браузера требует куда меньше памяти, а Apify берёт плату за память, умноженную на время. Actor.fail() ниже завершает запуск, а не возвращает управление, поэтому код после него может считать, что совпадение нашлось.

// Fetch the form page and lift the sitekey out of it.
const html = await (await fetch(input.targetUrl)).text();
const match = html.match(/data-sitekey="([^"]+)"/);

if (!match) {
  await Actor.fail('No sitekey on the page. Did the widget render?');
}

const result = await solver.recaptcha(match[1], input.targetUrl);
const token = result.code;   // the g-recaptcha-response value

Затем отправьте форму с токеном в том поле, которое ждёт сайт. Для reCAPTCHA v2 скрытая textarea называется g-recaptcha-response, и большинство форм отправляют токен под тем же именем. Если страница вместо этого передаёт токен в коллбэк, посмотрите, что именно отправляет коллбэк.

// The token travels as an ordinary form field.
const body = new URLSearchParams({
  email: 'someone@example.com',
  'g-recaptcha-response': token,
});

const posted = await fetch(input.targetUrl, { method: 'POST', body });
await Actor.pushData({ url: input.targetUrl, status: posted.status });

У Turnstile и GeeTest есть собственные методы у того же клиента, и оба устроены так же. Turnstile вдобавок возвращает user agent, который на страницах с проверкой надо отправлять вместе с токеном. Полные списки параметров приведены в документации CapSkip API.

Полный рабочий пример

Весь Actor целиком, как src/main.js. Actor представляет собой ES-модуль с top-level await, поэтому обёрточной функции нет, а Actor.exit() в конце сбрасывает датасет и аккуратно закрывает запуск.

// npm install apify capskip
import { Actor } from 'apify';
import { CapSkip, NetworkException, TimeoutException } from 'capskip';

await Actor.init();

const input = await Actor.getInput();
const solver = new CapSkip({
  host: Actor.isAtHome() ? input.solverHost : '127.0.0.1',
  port: 8080,
  apiKey: input.solverKey,
});

try {
  const html = await (await fetch(input.targetUrl)).text();
  const match = html.match(/data-sitekey="([^"]+)"/);
  if (!match) throw new Error('No sitekey found on the page.');

  const result = await solver.recaptcha(match[1], input.targetUrl);

  const body = new URLSearchParams({
    'g-recaptcha-response': result.code,
  });
  const posted = await fetch(input.targetUrl, { method: 'POST', body });

  await Actor.pushData({ url: input.targetUrl, status: posted.status });
} catch (err) {
  if (err instanceof NetworkException) {
    await Actor.fail('Cannot reach the solver. Check Server mode and the host.');
  }
  if (err instanceof TimeoutException) {
    await Actor.fail('The solve outlasted recaptchaTimeout.');
  }
  throw err;
}

await Actor.exit();

Ловить два исключения, связанных с соединением, по отдельности стоит этих шести строк. Actor.fail() работает как Actor.exit() с кодом выхода 1 и приложенным сообщением, поэтому запуск завершается статусом FAILED, а в логе остаётся фраза, которая говорит, какая половина настройки сломалась. Без этого просто недоступный решатель и капча, которую действительно не удалось распознать, дают один и тот же красный запуск.

Запуск локально перед развёртыванием

Сначала запустите Actor на своей машине с решателем в режиме Local. isAtHome() там возвращает false, поэтому код сам тянется к 127.0.0.1 без всяких правок, и вы успеваете убедиться, что чтение sitekey и отправка формы работают, до того как добавите сетевой переход.

# Reads INPUT from storage/key_value_stores/default.
apify run

Когда это пройдёт, переведите CapSkip в режим Server, посмотрите, какой адрес он теперь слушает, и впишите этот адрес во входной параметр solverHost на платформе. Изменилась ровно одна строка.

Частые ошибки и что они означают

Что вы видитеПричинаИсправить
NetworkException на платформе и никогда локальноActor обратился к 127.0.0.1 и попал в собственный контейнерПереведите решатель в режим Server и передайте его адрес
ERROR_WRONG_USER_KEYПроверка ключа включена, а Actor отправил не тот ключЗадайте ключ секретным входным параметром и читайте его оттуда
ERROR_GOOGLEKEYРегулярное выражение ничего не нашло, и наружу ушёл пустой ключПроверьте совпадение, прежде чем тратить на него решение
Статус запуска TIMED-OUTТаймаут запуска короче, чем fetch плюс решение плюс отправкаПоднимите таймаут в стандартных параметрах запуска Actor
TimeoutExceptionРешение заняло больше, чем recaptchaTimeoutПоднимите его выше стандартных 300 секунд
Форма отклоняет токен, который решился нормальноТокен устарел между решением и отправкойРешайте капчу прямо перед отправкой, а не в начале запуска

FAQ

Может ли Apify Actor и правда достучаться до решателя на моей машине?

Да, через тот же API, каким он пользовался бы для любого внутреннего сервиса. Режим Server заставляет CapSkip слушать ваш сетевой или публичный IP вместо адреса обратной петли, поэтому Actor обращается к нему как к любому HTTP-эндпоинту. Статический публичный IP рекомендуется, чтобы адрес не менялся от запуска к запуску, а проверку ключа стоит включить до того, как вы откроете порт.

Работает ли это внутри краулера Crawlee на Apify?

Работает, и три действия те же самые. Разница в том, где они стоят: чтение sitekey и вставка токена уходят внутрь обработчика запросов, а клиент решателя создаётся один раз снаружи, чтобы все запросы делили один экземпляр. Специфика краулера, включая то, почему не стоит оборачивать решение в собственный цикл повторов, разобрана в руководстве по капче для Crawlee.

Стоит ли решать капчу через Apify Proxy?

Только если сайт оценивает IP, с которого пришло решение. Поддержка прокси есть для reCAPTCHA, Turnstile и GeeTest, и она важна, когда токен сверяют с адресом, который его запросил. Передавайте прокси в самом вызове решения, а не заворачивайте через него весь решатель, чтобы загрузка страницы и решение при желании выходили через разные точки. Компромиссы разобраны в отдельном руководстве: ротация прокси при распознавании капчи.

Сколько памяти выделять Actor?

Меньше, чем кажется, если обойтись без браузера. Actor выше загружает HTML, ждёт сетевой вызов и отправляет форму, то есть большую часть жизни простаивает, и ничего похожего на аппетиты Chromium ему не нужно. Тянитесь за браузером только тогда, когда страница не отдаёт sitekey без выполнения JavaScript. Само решение в любом случае происходит на машине решателя, но ваш Actor всё это время продолжает работать.

Коротко

Переведите решатель в режим Server, храните его адрес и ключ как зашифрованные входные параметры и дайте Actor.isAtHome() выбирать между этим адресом и 127.0.0.1. Затем считайте sitekey, решите капчу и отправьте токен как g-recaptcha-response. Более широкую картину по Node, включая Puppeteer и Playwright, смотрите на странице сервиса распознавания капчи для Node.js. Та же задача со стороны обхода сайтов разобрана на странице о веб-скрейпинге.

Одно следствие стоит проговорить, прежде чем масштабировать Actor. Поскольку этот обход капчи работает на железе, которым вы уже владеете, переход от десяти запусков к тысяче меняет ваш счёт в Apify и оставляет счёт за решение капчи там же, где он был.