如何在 Bun 中识别 reCAPTCHA:使用 .env 文件和 bun test

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

要在 Bun 中识别 reCAPTCHA,就用 bun add 添加 CapSkip Node.js SDK。用 Bun 已经从你的 .env 文件中加载的值来构建客户端,然后调用识别工具并提交 token。CapSkip 运行在你自己的 Windows PC 上,所以没有按次识别的账单。代码很短,SDK 在 Bun 下无需改动就能用。有三个 Bun 的习惯,和 Node 开发者的预期不一样。Bun 会不经要求就读取 .env 文件,而且只从你运行它的文件夹里读。对挂起的套接字设置的超时会让 promise 被 reject,但不会结束进程。而且 bun test 会在五秒后停止每个测试,这比一次真实的识别还短。本指南会逐一讲到它们。

你需要什么

  • 在 Windows PC 上运行的 CapSkip,并已开启 reCAPTCHA 识别功能。 设置指南 会带你完成首次启动的每一步。
  • 适用于 Windows 的 Bun。下面的所有内容都在 Windows 11 上用 Bun 1.4.3 和来自 npm 的 capskip 1.3.0 运行过。没有测试更早的 Bun 版本。
  • 一个 PowerShell 提示符。这些命令使用 PowerShell 语法。
  • 一个表单中带有 reCAPTCHA v2 复选框的页面。示例登录的是一个带有 CSRF token 的 Django 风格表单。请把字段名改成与你的站点一致。
  • 识别工具的地址。在 Local 模式下,CapSkip 只在 127.0.0.1 上响应,仅供本机使用。在 Server 模式下,它监听你的网络地址或公网 IP,这样另一台机器上的脚本就能通过 API 调用它。两种模式的设置位置都是 连接设置.

下面的测量数据来自同一台 PC 上的一个模拟 CapSkip 和一个模拟登录页面。Bun 和 SDK 是真实的。在你自己的站点上,Bun 和 SDK 应该表现一致,但这里只用了模拟程序。识别耗时,以及登录页面的接受和拒绝规则,则只属于这两个模拟程序本身。

第 1 步:安装 Bun

Bun 自己的安装页面给出了下面的命令,也列出了 npm install -g bun。本指南的测试使用的是通过 npm 安装到项目文件夹中的 Bun 1.4.3。安装程序的命令本身没有在这里运行。之后请打开一个新的 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 add 之前先运行 bun init。如果文件夹里没有 package.json,bun add 会向上查找最近的一个,并改动那个文件。在一个放进诱饵父文件夹的测试文件夹上,它保留了父文件夹的其他包,并把 capskip 加到了父文件夹里。你自己的父文件夹可能是用户文件夹,也可能是 monorepo 的根目录。先创建项目文件,就能让安装落在你预期的位置。

# 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 或更高版本,并写出一个名为 bun.lock 的文本锁文件。SDK 是 CommonJS 的。下面用到的具名导入和 require 都能用,这与 Deno 不同,在 Deno 里 require 会失败。

跳过 init 还有第二项代价。Bun 通过逐级向上查找文件夹来解析导入。你的项目里没有 node_modules 时,它在测试用的 PC 上从父文件夹的 node_modules 里加载了一个较旧的 capskip 1.0.2。没有任何错误提示这一点。

第 3 步:把设置放进 .env 文件

在项目文件夹中创建一个名为 .env 的文件。Bun 在你的代码运行之前加载它,所以这些值同时存在于 Bun.env 和 process.env 中,而且两者始终一致。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 同样不会被读取。
  • 在 shell 中设置的变量优先。其次是 .env.local,然后是对应模式的文件(在 bun run 下是 .env.development,除非设置了 NODE_ENV),最后是普通的 .env。
  • bun init 生成的 .gitignore 涵盖了 .env 和 .env.local。它没有涵盖 .env.production、.env.test 或 .env.development,所以在提交之前把它们加进去。
  • 文件中支持引号、行尾注释和 ${OTHER} 展开。

第 4 步:构建 CapSkip 客户端

把接下来的几个代码块依次保存到一个名为 solve.ts 的文件里。第 4 步到第 9 步都写进这同一个文件,完整的文件在接近结尾处给出。有了这些默认值,脚本不需要任何配置就能运行。只有当你在 CapSkip 中开启了 API key validation 时,CAPSKIP_API_KEY 才有意义。

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 步:自己保存 cookie

登录表单需要显示它的那个页面所设置的 cookie,而 Bun 中的 fetch 不保存任何 cookie。用 getSetCookie 读取每一行 Set-Cookie。常用的 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 token

这一部分是你自己的,不是 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 的 timeout 选项只在两次轮询之间检查,所以当单个请求卡住时它帮不上忙。下面的代码块让识别与一个计时器赛跑。

// 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 保持存活,所以正常的识别一结束就会立即退出。测试中,一个给 1.8 秒的识别套上 330 秒时限的脚本,大约在 1.8 秒时结束。你从不清除的普通 setTimeout 则是另一回事。它让 Bun 一直存活到它触发为止,20 秒的时限用了 20.04 秒。请在 finally 块里清除这样的计时器,或者改用 signal。

麻烦在另一个方向。在挂起的套接字上时限用完时,promise 会立刻 reject,在 3 秒时限的测试中是 3.0 秒。然后 Bun 仍然存活,因为卡住的套接字让它一直开着。这就是第 9 步以 process.exit 收尾的原因。当计时器先触发,而 CapSkip 仍在回答尚未就绪时,也会发生同样的事。SDK 在后台继续轮询,进程一直活到 SDK 自己的超时,在限制为 12 时是 12.8 秒。

如果你希望脚本更快失败,就选一个更小的时限。测试用 4 秒运行了这个代码块,CapSkip 先是在提交时毫无回应,然后是在轮询时毫无回应。两种情况都打印了第 9 步的消息,并在大约 4.04 秒时以退出码 1 退出。正常的识别在大约 1.8 秒时以退出码 0 退出。

第 8 步:识别、提交并检查结果

下一个代码块就是登录。reCAPTCHA token 只能用一次,所以每次登录都要识别属于自己的那一个。有两个细节很重要。Django 这类严格的站点在 HTTPS 下会拒绝没有 Referer 的表单提交,而 fetch 不发送它,所以代码块自己设置了它。另外,fetch 默认会跟随 302,并在途中丢掉新的 cookie。所以代码块把 redirect 设为 manual,并从 302 上读取 cookie。请把字段名、用户名和 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 的错误和缓慢的识别是三个不同的异常类,在 Bun 下可以用 instanceof 把它们区分开。关闭的端口在大约 10 毫秒内引发了 NetworkException,消息是 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,或者 bun test 的 timeout 开关。三种方式都在 8 秒的模拟程序上通过了。bunfig.toml 里 test 段下的 timeout 键没有生效。测试仍然在 5000 毫秒时失败,而且 Bun 的 bunfig 文档里也没有列出这样的键。实时测试从 .env 文件中读取 LOGIN_URL,所以把它设为你的登录页面。把这个保存为 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 之前运行。还有,这个替身会泄漏:这个 mock 对其他测试文件仍然有效。直接运行 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

编译命令把 Bun 运行时、你的脚本和 SDK 放进同一个文件。这里它用了 0.3 秒,生成了一个 96.4 MB 的文件,大小就是 Bun 本身的大小。这个 exe 在 PATH 里没有 Bun 的情况下也能运行。.env 文件不会被固化进去。exe 在运行时读取它的工作文件夹里的 .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 运行脚本

脚本和识别工具不必共用一台 PC。在连接设置里把 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 通过普通 HTTP 与 CapSkip 通信,所以 API 密钥是以明文传输的。把这条链路放在专用网络或 VPN 里,并开启 API key validation。另外,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);
  }
}

在 Bun 中识别 reCAPTCHA 时的常见错误

你所看到的原因修复
this test timed out after 5000msbun test 在五秒时切断每个测试setDefaultTimeout,或者给 test 传第三个参数
login rejected: 403,仅出现在 solve.test.ts 中另一个文件里的 mock.module 仍然有效带 isolate 开关运行 bun test
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 没能识别这一个重试这次识别

常见问题

CapSkip Node.js SDK 能在 Bun 中使用吗?

能,在 Bun 1.4.3 和 capskip 1.3.0 上可以。导入、一次完整的识别、四个异常类和轮询的表现都与在 Node 里一样。针对同一个模拟程序,在 Bun 1.4.3 和 Node 25 中,识别都用了大约 1.8 秒。关于这套配置的纯 Node 版本,请参阅 在 Node.js 中识别 reCAPTCHA v2.

这和 Deno 有什么不同?

Deno 在你放行之前会拦截网络和文件访问,而且 AbortSignal 计时器会让 Deno 保持存活。Bun 没有权限标志,所以没有什么需要放行的。陷阱在于自动加载 .env、超时之后依然存在的挂起套接字,以及五秒的测试上限。 Deno 指南 介绍了权限方面的内容。

我可以同时跑多个识别任务吗?

可以,在多个识别调用上用 Promise.all 即可。在 Node SDK 里,AsyncCapSkip 只是 CapSkip 的另一个名字,所以它比普通客户端没有多出任何东西。要留意 CapSkip 自己的上限:reCAPTCHA 的 Max. Threads 设置默认为 10,多出来的任务要等待空闲线程。本指南没有在 Bun 下测量并发。

托管平台上的脚本能使用我的 CapSkip 吗?

托管平台运行在别人的机器上,所以它无法访问你 PC 上的 127.0.0.1。请在一台你能控制的 Windows 机器上以 Server 模式运行 CapSkip,配上静态公网 IP。把那个地址放进 .env 或平台的机密存储里。CapSkip 已经在它的端口上放行所有地址,所以要为其余地址添加一条阻止规则,并记住密钥是通过普通 HTTP 以明文传输的。本指南没有测试任何托管平台。

简短版结论

要在 Bun 中识别 reCAPTCHA,就在 bun add 之前先运行 bun init。把 CapSkip 的设置放在 .env 文件里,并自己把它们传给客户端。让每次识别都与一个计时器赛跑,并用 process.exit 结束脚本,这样挂起的套接字就无法让它一直存活。调高五秒的测试上限,并在实时测试旁边保留一个替身识别工具的测试。

一个每晚要识别几百次的脚本,次数很快就会累积起来。用在你自己的 Windows 机器上运行的 无限量验证码识别工具 方案来完成这些识别,它们就永远不会变成账单。