Как решить reCAPTCHA в Bun с файлами .env и bun test

solve recaptcha in bun - How to Solve reCAPTCHA in Bun With .env Files and bun test

Чтобы решить reCAPTCHA в Bun, добавьте Node.js SDK от CapSkip командой bun add. Создайте клиент из значений, которые Bun уже загрузил из вашего файла .env, затем вызовите решатель и отправьте токен. CapSkip работает на вашем собственном ПК с Windows, поэтому платы за каждое решение нет. Код короткий, и SDK работает под Bun как есть. Три особенности Bun отличаются от того, чего ждёт разработчик на Node. Bun читает файлы .env без вашей просьбы и только из той папки, из которой вы его запускаете. Таймаут на зависшем сокете отклоняет вызов, но не завершает процесс. А bun test останавливает каждый тест через пять секунд, что меньше времени реального решения. В этом руководстве разобрана каждая из них.

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

  • CapSkip, запущенный на ПК с Windows, с включённым решателем reCAPTCHA. Пошаговое руководство по настройке проведёт вас через первый запуск.
  • Bun для Windows. Всё ниже запускалось на Windows 11 с Bun 1.4.3 и capskip 1.3.0 из npm. Более старые версии Bun не тестировались.
  • Окно PowerShell. Команды написаны в синтаксисе PowerShell.
  • Страница с чекбоксом reCAPTCHA v2 в форме. Пример выполняет вход через форму в стиле Django, в которой есть CSRF-токен. Поменяйте имена полей под свой сайт.
  • Адрес решателя. В режиме Local CapSkip отвечает на 127.0.0.1 и только для этого устройства. В режиме Server он слушает ваш сетевой адрес или публичный IP, чтобы скрипт на другой машине мог обращаться к нему через API. Оба режима задаются в разделе Настройки подключения.

Замеры ниже получены на тестовой имитации CapSkip и тестовой странице входа на том же ПК. Bun и SDK настоящие. С вашим собственным сайтом Bun и SDK должны вести себя так же, но проверялась только имитация. Время решения, а также правила приёма и отклонения на странице входа относятся только к этой имитации.

Шаг 1: устанавливаем Bun

На странице установки самого Bun приведена команда ниже, а также указано npm install -g bun. Для тестов этого руководства использовался Bun 1.4.3, установленный через npm в папку проекта. Сама команда установщика здесь не запускалась. После этого откройте новое окно PowerShell, чтобы обновился PATH.

# PowerShell: Bun's official installer (or: npm install -g bun)
powershell -c "irm bun.sh/install.ps1|iex"

# Open a new PowerShell window so PATH is refreshed, then check:
bun --version

Шаг 2: сначала создаём проект

Запустите bun init до bun add. Если в папке нет package.json, bun add ищет ближайший такой файл выше по дереву папок и меняет его. В тестовой папке внутри папки-приманки он сохранил остальные пакеты родителя и добавил capskip в родительский файл. Ваша собственная родительская папка может оказаться папкой профиля или корнем монорепозитория. Если сначала создать файл проекта, установка останется там, где вы ожидаете.

# Make the folder, then create package.json BEFORE bun add.
mkdir captcha-bun
cd captcha-bun
bun init -y
bun add capskip

Команда init создаёт строгий tsconfig.json, index.ts, README.md, .gitignore и package.json с заданным типом модуля. Она также устанавливает TypeScript и определения типов Bun. Команда add сохраняет capskip версии 1.3.0 или новее и записывает текстовый lockfile bun.lock. SDK написан на CommonJS. Работают и именованный импорт, используемый ниже, и require, в отличие от Deno, где require не работает.

Если пропустить init, это обойдётся ещё в одну проблему. Bun находит импорт, поднимаясь по папкам вверх. Когда в вашем проекте нет node_modules, на тестовом ПК он загрузил более старый capskip 1.0.2 из node_modules в родительской папке. Никакой ошибки об этом не было.

Шаг 3: помещаем настройки в файл .env

Создайте в папке проекта файл с именем .env. Bun загружает его до запуска вашего кода, поэтому значения оказываются в Bun.env и process.env, и эти два объекта всегда совпадают. Сам SDK не читает никаких переменных окружения. Ваш скрипт читает три значения и передаёт их в SDK. Это следующий шаг. Строка LOGIN_URL нужна для живого теста ниже.

# .env in the folder you run bun from (bun init already git-ignores it)
CAPSKIP_HOST=127.0.0.1
CAPSKIP_PORT=8080
CAPSKIP_API_KEY=capskip
LOGIN_URL=https://example.com/login

Знайте правила, потому что случайный файл .env может преподнести сюрприз:

  • Bun читает файлы .env только из папки, в которой вы его запускаете. Если запустить скрипт по пути из другой папки, ничего не прочитается, а .env на одну папку выше тоже не читается.
  • Побеждает переменная, заданная в оболочке. Затем идёт .env.local, затем файл для текущего режима (под bun run им служит .env.development, если не задан NODE_ENV), и наконец обычный .env.
  • Файл .gitignore, созданный bun init, покрывает .env и .env.local. Он не покрывает .env.production, .env.test и .env.development, поэтому добавьте их до коммита.
  • В файле работают кавычки, комментарий в конце строки и подстановка ${OTHER}.

Шаг 4: создаём клиент CapSkip

Сохраняйте следующие блоки один за другим в файл solve.ts. Шаги с 4 по 9 целиком входят в этот один файл, а весь файл показан ближе к концу. Значения по умолчанию позволяют запустить скрипт без настройки. CAPSKIP_API_KEY нужен, только если вы включили проверку API-ключа в CapSkip.

import {
  ApiException,
  CapSkip,
  NetworkException,
  TimeoutException,
} from "capskip";

// Bun has already loaded .env into Bun.env. The SDK reads no
// environment variable itself, so pass the three values in.
const solver = new CapSkip({
  host: Bun.env.CAPSKIP_HOST ?? "127.0.0.1",
  port: Number(Bun.env.CAPSKIP_PORT ?? 8080),
  apiKey: Bun.env.CAPSKIP_API_KEY ?? "capskip",
});

Шаг 5: сами храним cookies

Форме входа нужна cookie со страницы, которая её показала, а fetch в Bun не хранит cookies. Читайте каждую строку Set-Cookie через getSetCookie. Обычный вызов headers.get склеивает строки через запятую, и запятая внутри даты Expires ломает разбиение.

// fetch keeps no cookies, so collect every Set-Cookie line.
// (headers.get("set-cookie") would glue them into one string.)
const jar = new Map<string, string>();

function keepCookies(response: Response): void {
  for (const line of response.headers.getSetCookie()) {
    const pair = line.split(";")[0] ?? "";
    const eq = pair.indexOf("=");
    jar.set(pair.slice(0, eq).trim(), pair.slice(eq + 1).trim());
  }
}

const cookies = () =>
  Array.from(jar, ([name, value]) => `${name}=${value}`).join("; ");

Шаг 6: читаем sitekey и CSRF-токен

Эта часть ваша, а не часть CapSkip. Она открывает страницу входа и достаёт два значения. Подстройте шаблоны под свою разметку.

// Load the login page, keep its cookies, and read the sitekey and
// the CSRF token. Adjust the two patterns to your page's markup.
async function loadForm(pageUrl: string) {
  const page = await fetch(pageUrl);
  keepCookies(page);
  const html = await page.text();

  const sitekey = html.match(/data-sitekey="([^"]+)"/)?.[1];
  const csrf = html.match(/name="csrfmiddlewaretoken" value="([^"]+)"/)?.[1];
  if (!sitekey || !csrf) throw new Error("sitekey or CSRF token not found");
  return { sitekey, csrf };
}

Шаг 7: добавляем таймаут, который работает

SDK не задаёт таймаут сокета. CapSkip, который принял вызов и никогда не отвечает, подвешивает скрипт. В тесте Bun ничего не выводил и не завершался, и всё ещё ждал через 400 секунд. Опция таймаута в SDK проверяется только между опросами, поэтому не помогает, пока застрял один запрос. Блок ниже запускает решение наперегонки с таймером.

// The SDK sets no socket timeout, so a CapSkip that accepts the call
// and never answers would hang the script. Race the solve against a
// timer. In Bun an AbortSignal.timeout does not keep the process alive.
function withTimeout<T>(work: Promise<T>, ms: number): Promise<T> {
  const signal = AbortSignal.timeout(ms);
  const limit = new Promise<never>((_, reject) => {
    signal.addEventListener("abort", () =>
      reject(new TimeoutException(`no answer from CapSkip in ${ms} ms`)),
    );
  });
  return Promise.race([work, limit]);
}

// The SDK gives up after 300 s by itself, so 330 s only fires when
// CapSkip goes silent. code is typed "string or object", hence String().
async function getToken(sitekey: string, pageUrl: string): Promise<string> {
  const solving = solver.recaptcha(sitekey, pageUrl);
  const { code } = await withTimeout(solving, 330_000);
  return String(code);
}

Здесь Bun отличается от Deno, и вам это на руку. Таймер от AbortSignal.timeout не удерживает Bun запущенным, поэтому обычное решение по-прежнему завершается, как только закончено. В тесте скрипт с лимитом 330 секунд вокруг решения в 1.8 секунды завершился примерно на 1.8 секунде. С обычным setTimeout, который вы не сбрасываете, всё иначе. Он удерживал Bun запущенным, пока не сработал: 20.04 секунды при лимите 20 секунд. Сбрасывайте такой таймер в блоке finally или используйте сигнал.

Подвох есть и в обратную сторону. Когда лимит времени истекает на зависшем сокете, промис отклоняется сразу: на 3.0 секунде в тесте с лимитом 3 секунды. После этого Bun остаётся запущенным, потому что застрявший сокет держит его открытым. Поэтому шаг 9 заканчивается вызовом process.exit. То же происходит, когда таймер сработал первым, а CapSkip всё ещё отвечает, что результат не готов. SDK продолжает опрашивать в фоне, и процесс живёт до собственного таймаута SDK, который составил 12.8 секунды при лимите 12.

Выберите меньший лимит, если скрипт должен падать быстрее. В тестах блок запускали с лимитом 4 секунды и с CapSkip, который замолкал сначала на отправке, а затем на опросе. В обоих случаях выводилось сообщение из шага 9, и программа завершалась с кодом 1 примерно на 4.04 секунде. Обычное решение завершалось с кодом 0 примерно на 1.8 секунде.

Шаг 8: решаем, отправляем и проверяем результат

Следующий блок выполняет вход. Токен reCAPTCHA работает один раз, поэтому каждый вход решает свою капчу. Важны две детали. Строгий сайт вроде Django по HTTPS отклоняет отправку формы без Referer, а fetch его не отправляет, поэтому блок задаёт его. И fetch по умолчанию идёт по ответу 302 и по пути теряет новую cookie. Поэтому блок использует redirect manual и читает cookie из ответа 302. Поменяйте имена полей, имя пользователя и маркер Welcome back под свой сайт.

// fetch sends no Referer, and a strict site rejects the post without
// one. redirect "manual" keeps the 302 so we can read its new cookie.
export async function login(pageUrl: string): Promise<boolean> {
  jar.clear();
  const { sitekey, csrf } = await loadForm(pageUrl);
  const token = await getToken(sitekey, pageUrl);

  const response = await fetch(pageUrl, {
    method: "POST",
    redirect: "manual",
    headers: { Cookie: cookies(), Referer: pageUrl },
    body: new URLSearchParams({
      csrfmiddlewaretoken: csrf,
      username: "YOUR_USERNAME",
      password: "YOUR_PASSWORD",
      "g-recaptcha-response": token,
    }),
  });
  keepCookies(response);
  if (response.status !== 302) {
    const body = await response.text();
    throw new Error(`login rejected: ${response.status} ${body}`);
  }

  // Swap "Welcome back" for a marker your own site shows after login.
  const next = new URL(response.headers.get("location") ?? "/", pageUrl);
  const home = await fetch(next, { headers: { Cookie: cookies() } });
  return (await home.text()).includes("Welcome back");
}

Шаг 9: сообщаем об ошибках и запускаем

Закрытый порт, ошибка от CapSkip и медленное решение дают три разных класса исключений, и instanceof различает их под Bun. Закрытый порт вызвал NetworkException примерно за 10 миллисекунд с сообщением connect ECONNREFUSED 127.0.0.1 и номером порта. Имя хоста localhost сработало почти так же быстро, но оставило сообщение пустым, а код ошибки оказался в cause. Блок использует этот код как запасной вариант.

// A closed port, a bad reply and a slow solve are three different
// exception classes. A host NAME can leave the message empty, so fall
// back to the error code on .cause.
function explain(err: unknown): string {
  if (err instanceof NetworkException) {
    const code = (err.cause as { code?: string } | undefined)?.code;
    return `CapSkip is not reachable: ${err.message || code}`;
  }
  if (err instanceof ApiException) return `CapSkip replied: ${err.message}`;
  if (err instanceof TimeoutException) return `Too slow: ${err.message}`;
  return err instanceof Error ? (err.stack ?? err.message) : String(err);
}

// import.meta.main is true only when this file is run directly, so
// the tests can import login() without starting a login.
if (import.meta.main) {
  try {
    const ok = await login(Bun.argv[2] ?? "https://example.com/login");
    console.log(ok ? "logged in" : "NOT logged in");
    process.exit(ok ? 0 : 2);
  } catch (err) {
    console.error(explain(err));
    // A hung socket would keep Bun alive, so leave explicitly.
    process.exit(1);
  }
}

Замените URL из примера на свою страницу входа и запустите скрипт из папки проекта. Bun подхватывает там файл .env. Переменная, заданная в PowerShell, главнее одноимённой переменной в файле и остаётся заданной до конца этой сессии PowerShell, поэтому блок снова её очищает. Последние две команды отключают автоматическую загрузку или читают вместо этого выбранный вами файл. Сначала создайте этот файл. Отсутствующий файл пропускается без сообщения, и .env тоже не читается.

# Run it. Bun reads .env from the folder you run this in.
bun run solve.ts https://example.com/login

# A variable set in PowerShell beats the same name in .env.
$env:CAPSKIP_API_KEY = "my-key"
bun run solve.ts https://example.com/login
Remove-Item Env:CAPSKIP_API_KEY

# Ignore .env completely, or read a different file instead. The second
# file must exist: a missing one is skipped and .env is not read either.
bun run --no-env-file solve.ts https://example.com/login
bun run --env-file=.env.office solve.ts https://example.com/login

Удачный запуск выводит logged in и завершается с кодом 0. На имитации решение, которое имитация держала одну секунду, заняло 1.77 секунды. SDK опрашивает сразу, затем ждёт 0.25, 0.5, 1, 2 и 4 секунды, а после этого по 5 секунд. Короткое решение не округляется до ровных пяти.

Тестируем с bun test

В Bun есть собственный тестовый раннер. Он запускает файлы с именами вроде solve.test.ts, и его лимит по умолчанию составляет 5000 миллисекунд на тест. Настоящее решение reCAPTCHA может занять дольше. Тест, который ждал имитацию с решением в 8 секунд, упал вот с таким результатом, и прогон завершился с кодом 1:

  • (fail) logs in through CapSkip [5001.15ms]
  • this test timed out after 5000ms.

Поднять лимит можно одним из трёх способов: третьим аргументом test, через setDefaultTimeout, как показано ниже, или ключом timeout у bun test. Все три сработали на имитации с решением в 8 секунд. Ключ timeout в разделе test файла bunfig.toml не сработал. Тест всё равно упал на 5000 миллисекундах, а в документации Bun по bunfig такого ключа нет. Живой тест читает LOGIN_URL из файла .env, поэтому укажите там свою страницу входа. Сохраните этот блок как solve.test.ts.

import { expect, setDefaultTimeout, test } from "bun:test";
import { login } from "./solve";

// bun test stops every test after 5 s, and a real solve can take
// longer. Raise the limit for the whole file (or pass a number as the
// third argument of test(), or run: bun test --timeout 120000).
setDefaultTimeout(120_000);

test("logs in through CapSkip", async () => {
  const ok = await login(Bun.env.LOGIN_URL ?? "https://example.com/login");
  expect(ok).toBe(true);
});

Большинству ваших тестов решатель вообще не нужен. Второй тест заменяет SDK заглушкой и работает с небольшим сайтом-заменителем, поэтому проходит при закрытом CapSkip. В двух вещах легко ошибиться. Импорт solve.ts должен быть динамическим, потому что статический импорт выполняется раньше, чем mock.module. И заглушка выходит за пределы своего файла: подмена остаётся в силе для остальных файлов тестов. Обычный bun test заставил solve.test.ts упасть с login rejected: 403, потому что тот теперь использовал поддельный решатель. Сохраните этот блок как fake.test.ts.

import { expect, mock, test } from "bun:test";

// Replace the SDK before solve.ts loads it. The import must be dynamic,
// because a static import runs before mock.module() does.
mock.module("capskip", () => ({
  CapSkip: class {
    async recaptcha() {
      return { captchaId: "1", code: "fake-token" };
    }
  },
  ApiException: class extends Error {},
  NetworkException: class extends Error {},
  TimeoutException: class extends Error {},
}));
const { login } = await import("./solve");

// A tiny stand-in login site: it only accepts the fake token.
const site = Bun.serve({
  port: 0,
  async fetch(req) {
    const url = new URL(req.url);
    if (req.method === "GET" && url.pathname === "/login") {
      return new Response(
        '<div data-sitekey="K"></div>' +
          '<input name="csrfmiddlewaretoken" value="C">',
      );
    }
    if (req.method === "POST") {
      const form = await req.formData();
      if (form.get("g-recaptcha-response") !== "fake-token")
        return new Response("bad token", { status: 403 });
      return new Response(null, {
        status: 302,
        headers: { Location: "/home" },
      });
    }
    return new Response("Welcome back");
  },
});

test("login posts the token CapSkip returned", async () => {
  expect(await login(`http://127.0.0.1:${site.port}/login`)).toBe(true);
  await site.stop();
});

Исправляет это ключ isolate, который даёт каждому файлу новый глобальный объект. С ним оба файла прошли. Кроме того, Bun во время bun test задаёт NODE_ENV равным test, поэтому загружает .env.test и пропускает .env.local.

# Run every *.test.ts file. --isolate gives each file a fresh global
# object, so the fake SDK in fake.test.ts cannot leak into the others.
bun test --isolate

# The default limit is 5 s per test. Raise it for all tests (ms).
bun test --isolate --timeout 120000

Сборка одного .exe

Команда compile упаковывает среду выполнения Bun, ваш скрипт и SDK в один файл. Здесь она заняла 0.3 секунды и создала файл в 96.4 МБ, размером с сам Bun. Файл exe запускался с PATH, в котором не было Bun. Файл .env внутрь не зашит. Exe читает .env из папки, из которой запущен, во время выполнения. Он не использует ни .env из папки сборки, ни .env рядом с exe. При запуске из папки без .env порт возвращается как undefined, и действуют значения по умолчанию из шага 4. В Task Scheduler укажите в поле Start in папку, где лежит ваш .env. Добавьте в команду сборки ключ no-compile-autoload-dotenv, и exe будет игнорировать этот файл. Настоящие переменные окружения по-прежнему работают.

# One .exe with the Bun runtime and the SDK inside. Run it from the
# folder that holds your .env (Task Scheduler: set "Start in").
bun build --compile --outfile solve.exe solve.ts
.\solve.exe https://example.com/login

Проверка типов по запросу

Команда bun run убирает типы и никогда их не проверяет. Файл с ошибкой типов всё равно запустился и выполнил вход. В Bun 1.4.3 есть команда bun check, которая прогоняет TypeScript по проекту, и оба файла тестов оказались чистыми. Стоит знать три особенности типов в SDK. Поле code объявлено как строка или объект, поэтому шаг 7 оборачивает его в String. Поле token необязательное. А тип опций вызова recaptcha принимает любое имя, поэтому опечатка в опции компилируется. Опечатка в опции конструктора, например hots, считается ошибкой.

# bun run never type checks. Check on demand (TypeScript from bun init):
bun check

Запуск скрипта против CapSkip на другой машине

Скрипту и решателю не обязательно работать на одном ПК. Переключите CapSkip в режим Server в настройках подключения, и он будет слушать ваш сетевой адрес или публичный IP. Укажите этот адрес в .env на машине, где запускается скрипт. Для стабильного удалённого доступа рекомендуется статический публичный IP. Решателем по-прежнему остаётся ваша собственная машина с Windows, и решения по-прежнему не тарифицируются.

# .env on the PC that runs the script, with CapSkip in Server mode
# on another PC. Use that PC's address, and an API key if you set one.
CAPSKIP_HOST=192.168.1.50
CAPSKIP_PORT=8080
CAPSKIP_API_KEY=your-long-random-key

Обратите внимание на две вещи. SDK обращается к CapSkip по обычному HTTP, поэтому API-ключ передаётся открытым текстом. Держите это соединение в частной сети или VPN и включите проверку API-ключа. При запуске режима Server CapSkip открывает свой порт для всех адресов. Чтобы ограничить доступ ожидаемыми машинами, добавьте в Windows Firewall правило, блокирующее все остальные. Для этого руководства удалённая настройка не запускалась. Разбирался только файл .env.

Весь solve.ts

Это шаги с 4 по 9 в одном файле, для быстрого старта. Если вы сохраняли блоки по одному, он у вас уже есть. Не вставляйте файл дважды, иначе Bun сообщит, что имя уже объявлено.

import {
  ApiException,
  CapSkip,
  NetworkException,
  TimeoutException,
} from "capskip";

// Bun has already loaded .env into Bun.env. The SDK reads no
// environment variable itself, so pass the three values in.
const solver = new CapSkip({
  host: Bun.env.CAPSKIP_HOST ?? "127.0.0.1",
  port: Number(Bun.env.CAPSKIP_PORT ?? 8080),
  apiKey: Bun.env.CAPSKIP_API_KEY ?? "capskip",
});

// fetch keeps no cookies, so collect every Set-Cookie line.
// (headers.get("set-cookie") would glue them into one string.)
const jar = new Map<string, string>();

function keepCookies(response: Response): void {
  for (const line of response.headers.getSetCookie()) {
    const pair = line.split(";")[0] ?? "";
    const eq = pair.indexOf("=");
    jar.set(pair.slice(0, eq).trim(), pair.slice(eq + 1).trim());
  }
}

const cookies = () =>
  Array.from(jar, ([name, value]) => `${name}=${value}`).join("; ");

// Load the login page, keep its cookies, and read the sitekey and
// the CSRF token. Adjust the two patterns to your page's markup.
async function loadForm(pageUrl: string) {
  const page = await fetch(pageUrl);
  keepCookies(page);
  const html = await page.text();

  const sitekey = html.match(/data-sitekey="([^"]+)"/)?.[1];
  const csrf = html.match(/name="csrfmiddlewaretoken" value="([^"]+)"/)?.[1];
  if (!sitekey || !csrf) throw new Error("sitekey or CSRF token not found");
  return { sitekey, csrf };
}

// The SDK sets no socket timeout, so a CapSkip that accepts the call
// and never answers would hang the script. Race the solve against a
// timer. In Bun an AbortSignal.timeout does not keep the process alive.
function withTimeout<T>(work: Promise<T>, ms: number): Promise<T> {
  const signal = AbortSignal.timeout(ms);
  const limit = new Promise<never>((_, reject) => {
    signal.addEventListener("abort", () =>
      reject(new TimeoutException(`no answer from CapSkip in ${ms} ms`)),
    );
  });
  return Promise.race([work, limit]);
}

// The SDK gives up after 300 s by itself, so 330 s only fires when
// CapSkip goes silent. code is typed "string or object", hence String().
async function getToken(sitekey: string, pageUrl: string): Promise<string> {
  const solving = solver.recaptcha(sitekey, pageUrl);
  const { code } = await withTimeout(solving, 330_000);
  return String(code);
}

// fetch sends no Referer, and a strict site rejects the post without
// one. redirect "manual" keeps the 302 so we can read its new cookie.
export async function login(pageUrl: string): Promise<boolean> {
  jar.clear();
  const { sitekey, csrf } = await loadForm(pageUrl);
  const token = await getToken(sitekey, pageUrl);

  const response = await fetch(pageUrl, {
    method: "POST",
    redirect: "manual",
    headers: { Cookie: cookies(), Referer: pageUrl },
    body: new URLSearchParams({
      csrfmiddlewaretoken: csrf,
      username: "YOUR_USERNAME",
      password: "YOUR_PASSWORD",
      "g-recaptcha-response": token,
    }),
  });
  keepCookies(response);
  if (response.status !== 302) {
    const body = await response.text();
    throw new Error(`login rejected: ${response.status} ${body}`);
  }

  // Swap "Welcome back" for a marker your own site shows after login.
  const next = new URL(response.headers.get("location") ?? "/", pageUrl);
  const home = await fetch(next, { headers: { Cookie: cookies() } });
  return (await home.text()).includes("Welcome back");
}

// A closed port, a bad reply and a slow solve are three different
// exception classes. A host NAME can leave the message empty, so fall
// back to the error code on .cause.
function explain(err: unknown): string {
  if (err instanceof NetworkException) {
    const code = (err.cause as { code?: string } | undefined)?.code;
    return `CapSkip is not reachable: ${err.message || code}`;
  }
  if (err instanceof ApiException) return `CapSkip replied: ${err.message}`;
  if (err instanceof TimeoutException) return `Too slow: ${err.message}`;
  return err instanceof Error ? (err.stack ?? err.message) : String(err);
}

// import.meta.main is true only when this file is run directly, so
// the tests can import login() without starting a login.
if (import.meta.main) {
  try {
    const ok = await login(Bun.argv[2] ?? "https://example.com/login");
    console.log(ok ? "logged in" : "NOT logged in");
    process.exit(ok ? 0 : 2);
  } catch (err) {
    console.error(explain(err));
    // A hung socket would keep Bun alive, so leave explicitly.
    process.exit(1);
  }
}

Частые ошибки при решении reCAPTCHA в Bun

Что вы видитеПричинаИсправить
this test timed out after 5000msbun test обрывает каждый тест через пять секундsetDefaultTimeout или третий аргумент test
login rejected: 403 только в solve.test.tsmock.module в другом файле всё ещё действуетЗапускайте bun test с ключом isolate
NetworkException с пустым сообщениемХост указан именем, например localhost, поэтому ошибка находится в causeВыводите err.cause.code или используйте 127.0.0.1
NetworkException: connect ECONNREFUSED 127.0.0.1CapSkip не запущен либо неверно указан портЗапустите CapSkip и проверьте порт в настройках подключения
Object is not a constructorИмпорт по умолчанию отдаёт весь объект exportsИмпортируйте CapSkip по имени, в фигурных скобках
Скрипт зависает и ничего не выводитCapSkip принял вызов и замолчалИспользуйте гонку withTimeout и завершайте скрипт через process.exit
Загружается не та версия capskipВ проекте нет node_modules, поэтому Bun нашёл более старую копию вышеЗапускайте bun init и bun add внутри папки проекта
В exe настройки возвращаются как undefinedExe читает .env из своей рабочей папкиЗапускайте из этой папки или задайте Start in
fetch к 127.0.0.1 падает с ECONNREFUSEDHTTP_PROXY указывает на неработающий прокси, и fetch его используетЗадайте NO_PROXY равным 127.0.0.1,localhost
ApiException: ERROR_CAPTCHA_UNSOLVABLECapSkip не смог решить эту капчуПовторите решение

FAQ

Работает ли Node.js SDK от CapSkip в Bun?

Да, на Bun 1.4.3 с capskip 1.3.0. Импорт, полное решение, четыре класса исключений и опрос вели себя так же, как в Node. Решение заняло около 1.8 секунды и в Bun 1.4.3, и в Node 25 на одной и той же имитации. Об обычной версии этой настройки для Node читайте в руководстве по решению reCAPTCHA v2 в Node.js.

Чем это отличается от Deno?

Deno блокирует доступ к сети и файлам, пока вы его не разрешите, а таймер AbortSignal удерживает Deno запущенным. У Bun флагов разрешений нет, поэтому разрешать нечего. Ловушки здесь такие: автоматическая загрузка .env, зависший сокет, переживающий таймаут, и пятисекундный лимит теста. Тему разрешений руководство по Deno разбирает подробно.

Можно ли решать несколько капч одновременно?

Да, через Promise.all для нескольких вызовов решателя. В Node SDK AsyncCapSkip служит лишь другим именем для CapSkip, поэтому он ничего не добавляет к обычному клиенту. Учитывайте собственный лимит CapSkip: настройка reCAPTCHA Max. Threads по умолчанию равна 10, а лишние задания ждут свободного потока. Параллельность для этого руководства под Bun не измерялась.

Может ли скрипт на хостинговой платформе использовать мой CapSkip?

Хостинговая платформа работает на чужой машине, поэтому не может обратиться к 127.0.0.1 на вашем ПК. Запустите CapSkip в режиме Server на машине с Windows, которой вы управляете, со статическим публичным IP. Укажите этот адрес в .env или в хранилище секретов платформы. CapSkip уже разрешает все адреса на своём порту, поэтому добавьте блокирующее правило для остальных адресов и помните, что ключ передаётся открытым текстом по обычному HTTP. Ни одна хостинговая платформа для этого руководства не тестировалась.

Коротко

Чтобы решить reCAPTCHA в Bun, запускайте bun init до bun add. Храните настройки CapSkip в файле .env и передавайте их клиенту сами. Запускайте каждое решение наперегонки с таймером и заканчивайте скрипт вызовом process.exit, чтобы зависший сокет не удерживал его запущенным. Поднимите пятисекундный лимит теста и держите рядом с живым тестом тест с заглушкой решателя.

Скрипт, который несколько сотен раз за ночь решает капчу, быстро накапливает много решений. Когда безлимитный сервис распознавания капчи выполняется на вашей собственной машине с Windows, эти решения никогда не превращаются в счёт.