Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,35 @@ All notable changes to this project are documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [1.2.0] - 2026-09-12

### Added

- **ALTCHA support** via `altcha(url, options)`. Pass `challengeUrl` for CapSkip
to fetch the challenge, or `challengeJson` with the document itself (a JSON
string, or an object which is serialized for you). Sending both is allowed —
the inline document wins. The result exposes `token` (the value the site's
`altcha` form field expects) and `number`, the counter that solved it; `code`
keeps the same raw string.
- Parameter aliases `challengeUrl`/`challengeURL` for `challenge_url` and
`challengeJson`/`challengeJSON` for `challenge_json`.
- `AltchaOptions` and the `token`/`number` result fields in the TypeScript
definitions.
- Both ALTCHA generations are handled: the legacy scheme (SHA-1/256/384/512) and
proof-of-work v2 (PBKDF2 or SHA). Their tokens are shaped differently — a v2
payload carries no top-level `number`, its counter sitting at
`solution.counter` — so the counter is taken from the server's own `solution`
object, the one field both report the same way, and dug out of the token only
when a poll did not carry it.

### Notes

- ALTCHA is CPU proof-of-work rather than a browser solve, so it uses
`defaultTimeout` instead of the longer `recaptchaTimeout` that reCAPTCHA,
Turnstile and GeeTest use.
- A proxy passed to `altcha()` applies only to the `challengeUrl` fetch; a task
carrying its challenge inline never touches the network.

## [1.1.0] - 2026-07-26

### Added
Expand Down
33 changes: 27 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
[![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE)
[![Tests](https://github.com/capskip/capskip-node/actions/workflows/ci.yml/badge.svg)](https://github.com/capskip/capskip-node/actions/workflows/ci.yml)

**Solve reCAPTCHA v2, reCAPTCHA v3, Cloudflare Turnstile, GeeTest and image captchas from Node.js.**
**Solve reCAPTCHA v2, reCAPTCHA v3, Cloudflare Turnstile, GeeTest, ALTCHA and image captchas from Node.js.**

Official Node.js client for [CapSkip](https://capskip.com), a **local captcha solver** that runs on your own machine. Licensed once, not billed per solve. TypeScript definitions included.

Expand All @@ -19,7 +19,7 @@ npm install capskip

CapSkip is a desktop app. It does the solving on your machine and exposes the standard captcha-solver HTTP API — the same `in.php` / `res.php` endpoints every 2captcha-compatible client already speaks — on `127.0.0.1:8080`.

This SDK is a thin wrapper over that API, with the method names you would expect: `normal()`, `recaptcha()`, `turnstile()`, `geetest()`. Nothing leaves your network, and there is no credit balance to keep an eye on.
This SDK is a thin wrapper over that API, with the method names you would expect: `normal()`, `recaptcha()`, `turnstile()`, `geetest()`, `altcha()`. Nothing leaves your network, and there is no credit balance to keep an eye on.

## Supported captcha types

Expand All @@ -34,6 +34,7 @@ This SDK is a thin wrapper over that API, with the method names you would expect
| **Cloudflare Turnstile solver** (widget) | `solver.turnstile(sitekey, url)` |
| Cloudflare Turnstile (challenge page) | `solver.turnstile(sitekey, url, { data, pagedata })` |
| **GeeTest v3 solver** (slide puzzle) | `solver.geetest(gt, challenge, url)` |
| **ALTCHA solver** (proof-of-work) | `solver.altcha(url, { challengeUrl })` |

**hCaptcha and FunCaptcha/Arkose are not supported.** hCaptcha is the one people misidentify most often, since it also puts a `data-sitekey` on the widget — check for `class="h-captcha"` or a `js.hcaptcha.com` script before reaching for `recaptcha()`.

Expand Down Expand Up @@ -131,6 +132,7 @@ const solver = new CapSkip({
port: 8080, // CapSkip port from app settings
defaultTimeout: 120, // seconds — image captcha polling timeout
recaptchaTimeout: 300, // seconds — reCAPTCHA / Turnstile / GeeTest polling timeout
// (ALTCHA uses defaultTimeout — CPU work, not a browser solve)
pollingInterval: 5, // max seconds between res.php polls (starts at 0.25s, backs off to this)
});
```
Expand Down Expand Up @@ -210,7 +212,25 @@ const result = await solver.geetest(
result.challenge, result.validate, result.seccode;
```

### With a proxy (reCAPTCHA, Turnstile & GeeTest only)
### ALTCHA

ALTCHA is proof-of-work, not recognition — there is nothing to read, so a solve
is deterministic and takes milliseconds. Give CapSkip the endpoint that serves
the challenge, or the challenge document itself.

```js
const result = await solver.altcha('https://example.com/signup', {
challengeUrl: 'https://example.com/captcha/api/altcha/challenge',
});

// Post this back in the form field the widget uses, named `altcha`
result.token;
```

Challenges expire fast — some sites inside two minutes — so fetch one
immediately before solving and submit the token promptly.

### With a proxy (reCAPTCHA, Turnstile, GeeTest & ALTCHA only)

```js
// Proxy is not supported for image captcha
Expand Down Expand Up @@ -305,7 +325,8 @@ Every solve method resolves to:
```

GeeTest additionally expands its answer into `challenge`, `validate`, and
`seccode`, while `code` keeps the raw JSON string.
`seccode`, while `code` keeps the raw JSON string. ALTCHA adds `token` (the same
string as `code`) and `number`, the counter that solved it.

---

Expand Down Expand Up @@ -350,15 +371,15 @@ const result: SolveResult = await solver.recaptcha('...', 'https://example.com')

### How do I solve a captcha in Node.js?

Install the CapSkip desktop app, `npm install capskip`, then call the method that matches the widget — `recaptcha()`, `turnstile()`, `geetest()` or `normal()`. Each returns a Promise that resolves once CapSkip has an answer, giving you a token, or the recognized text in the case of an image captcha.
Install the CapSkip desktop app, `npm install capskip`, then call the method that matches the widget — `recaptcha()`, `turnstile()`, `geetest()`, `altcha()` or `normal()`. Each returns a Promise that resolves once CapSkip has an answer, giving you a token, or the recognized text in the case of an image captcha.

### Is this a free captcha solver?

The SDK itself is MIT-licensed and free. Solving needs the CapSkip app, which is bought once rather than metered per captcha, so your cost stops scaling with volume.

### Which captchas can it solve?

reCAPTCHA v2 (checkbox and invisible), reCAPTCHA v3, reCAPTCHA Enterprise, Cloudflare Turnstile, GeeTest v3, and image/text captchas. Not hCaptcha, and not FunCaptcha/Arkose.
reCAPTCHA v2 (checkbox and invisible), reCAPTCHA v3, reCAPTCHA Enterprise, Cloudflare Turnstile, GeeTest v3, ALTCHA, and image/text captchas. Not hCaptcha, and not FunCaptcha/Arkose.

### Does it work with Puppeteer and Playwright?

Expand Down
126 changes: 125 additions & 1 deletion docs/API_REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,10 @@ method returns a `Promise`.
| reCAPTCHA v3 | `recaptcha(sitekey, url, { version: 'v3' })` | `userrecaptcha` + `version=v3` |
| Cloudflare Turnstile | `turnstile()` | `turnstile` |
| GeeTest v3 (slide) | `geetest()` | `geetest` |
| ALTCHA (proof-of-work) | `altcha()` | `altcha` |

**Proxy** is supported for reCAPTCHA, Turnstile, and GeeTest — not for image captcha.
**Proxy** is supported for reCAPTCHA, Turnstile, GeeTest, and ALTCHA — not for image
captcha. For ALTCHA the proxy is used only for the `challengeUrl` fetch.

---

Expand Down Expand Up @@ -267,6 +269,114 @@ rather than `defaultTimeout`.

---

## 6. ALTCHA — `altcha(url, { ... })`

ALTCHA is not a recognition captcha. There is no image, audio or text to read:
the site issues a proof-of-work challenge and the client must brute-force a
number that satisfies it. A solve is therefore deterministic and cheap —
typically milliseconds.

### POST `/in.php`

| Parameter | Type | Required | Description |
|---|---|---|---|
| `key` | string | Yes | CapSkip API key |
| `method` | string | Yes | `altcha` |
| `pageurl` | string | Yes | Full URL of the page the challenge came from |
| `challenge_url` | string | One of the two | Endpoint CapSkip fetches the challenge from |
| `challenge_json` | string | One of the two | The challenge document itself, as a JSON string |
| `json` | int | No | `0` plain text, `1` JSON |
| `proxy` | string | No | Proxy address — used **only** for the `challenge_url` fetch |
| `proxytype` | string | No | Proxy type |

Sending both challenge parameters is allowed: the inline `challengeJson` wins,
because fetching would only re-obtain what you already supplied.

### Getting the challenge

Open DevTools → Network on the target page and look for the request the
`<altcha-widget>` makes for its challenge (often something like
`/altcha/challenge`). The request URL is your `challengeUrl`; its JSON response
is your `challengeJson`.

The widget attribute that names that endpoint depends on the widget version, so
read the page source rather than assuming:

| Widget | Attribute |
|---|---|
| v1 / v2 | `challengeurl="…"`, with a separate `challengejson="…"` for an inline challenge |
| v3+ | `challenge="…"` — the same attribute takes either a URL or the challenge data |

> **Challenges expire, and the window is short** — some sites inside two minutes.
> Once expired, the site refuses the solution with a bare "verification failed"
> that looks exactly like a wrong answer. Fetch the challenge immediately before
> solving and submit the token promptly; do not fetch a batch in advance, and do
> not hold a token while a user fills in a form.
>
> CapSkip refuses an already-expired inline challenge immediately rather than
> burning CPU on a token that cannot work. If you passed `challengeUrl` and the
> challenge expired while the job queued, it fetches a fresh one automatically.

### SDK usage

```js
// CapSkip fetches the challenge for you
const result = await solver.altcha('https://example.com/signup', {
challengeUrl: 'https://example.com/captcha/api/altcha/challenge',
});

// …or hand it the document you already have. No network request at all.
const inline = await solver.altcha('https://example.com/signup', {
challengeJson: {
algorithm: 'SHA-256', challenge: '…', salt: '…',
signature: '…', maxnumber: 1000000,
},
});

result.token; // the base64 payload to post back
result.number; // the counter that solved it
result.code; // the same string as token
```

`challengeJson` accepts an object (serialized for you) or a JSON string.

`number` is reported for both ALTCHA generations. Their tokens differ — a legacy
payload carries it as a top-level `number`, while a proof-of-work v2 payload has
none, its counter sitting at `solution.counter` — so it is read from the server's
own `solution` object, which reports both the same way.

Post the token back in the form field the widget uses, named `altcha`:

```js
await fetch(SIGNUP_URL, {
method: 'POST',
body: new URLSearchParams({
email: 'someone@example.com',
altcha: result.token,
}),
});
```

Do not re-encode, trim or re-order the token: it is base64 of a JSON document
whose fields are covered by the server's HMAC signature, so any modification
invalidates it. Some integrations read the payload from a JSON body field
instead — check what the page's own submit sends and mirror it.

Unlike GeeTest and reCAPTCHA this is CPU proof-of-work rather than a browser
solve, so it uses `defaultTimeout`, not `recaptchaTimeout`.

### Algorithms

CapSkip supports the legacy scheme (SHA-1/256/384/512) and PoW v2 with PBKDF2 or
SHA. **Argon2id and scrypt are refused**, not attempted: a task using one returns
`ERROR_CAPTCHA_UNSOLVABLE` and is never retried. ALTCHA itself recommends PBKDF2
as the default, so this affects a minority of sites.

All three widget types (`native`, `checkbox`, `switch`) work — the distinction is
purely visual and never reaches CapSkip.

---

## Return value

Every solve method resolves to:
Expand All @@ -279,6 +389,18 @@ Every solve method resolves to:
}
```

ALTCHA additionally exposes `token` (the same string as `code`, named for the
form field it goes in) and `number`, the counter that solved it:

```js
{
captchaId: '12345',
code: 'eyJhbGdvcml0aG0iOiJTSEEtMjU2Iiwi…',
token: 'eyJhbGdvcml0aG0iOiJTSEEtMjU2Iiwi…',
number: 9661,
}
```

GeeTest additionally expands its answer into `challenge`, `validate`, and
`seccode` (`code` keeps the raw JSON string):

Expand Down Expand Up @@ -307,6 +429,8 @@ Convenience aliases mapped before sending to CapSkip:
| `data_s` | `data-s` |
| `apiServer` | `api_server` |
| `api_subdomain` | `api_server` |
| `challengeUrl` / `challengeURL` | `challenge_url` |
| `challengeJson` / `challengeJSON` | `challenge_json` |
| `proxy` object | `proxy` + `proxytype` strings |

```js
Expand Down
103 changes: 103 additions & 0 deletions examples/altcha.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
'use strict';

/**
* Solve an ALTCHA proof-of-work challenge.
*
* ALTCHA is not a recognition captcha -- there is nothing to read. The site
* issues a challenge and the browser must brute-force a number that satisfies
* it. CapSkip does that work for you, in milliseconds.
*
* You need the challenge, in one of two forms:
*
* * `challengeUrl` - the endpoint that serves it; CapSkip fetches it for you
* * `challengeJson` - the challenge document itself, if you already have it
*
* To find them, open DevTools -> Network on the target page and look for the
* request the `<altcha-widget>` makes for its challenge (often something like
* `/altcha/challenge`). The request URL is your `challengeUrl`; its JSON
* response is your `challengeJson`.
*
* Note the widget attribute that names the endpoint changed between versions:
* v1/v2 use `challengeurl="..."`, while v3+ uses `challenge="..."` for both a
* URL and inline data. Read the page source rather than assuming.
*
* Challenges expire fast -- some sites inside two minutes -- so fetch one
* immediately before solving and post the token promptly. An expired challenge
* is rejected with a bare "verification failed" that looks exactly like a wrong
* answer.
*
* This example issues its own challenge the way a site's server would, so it
* runs as-is with no third-party dependency. Swap in your target's endpoint to
* use it for real.
*/

const crypto = require('crypto');

const { CapSkip } = require('../src');

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_URL = 'https://example.com/signup';

/**
* Mint an ALTCHA challenge, exactly as a site's own server would.
*
* Replace this with a fetch of your target's challenge endpoint -- or skip it
* entirely and pass `challengeUrl` so CapSkip does the fetching.
*/
function issueChallenge(number = 54321) {
const salt = `${crypto.randomBytes(12).toString('hex')}?expires=${
Math.floor(Date.now() / 1000) + 600}`;
return {
algorithm: 'SHA-256',
challenge: crypto.createHash('sha256').update(`${salt}${number}`).digest('hex'),
salt,
signature: '0'.repeat(64),
maxnumber: 100000,
};
}

(async () => {
// --- Option A: you already have the challenge document ---------------------
// No network request at all: CapSkip solves it locally.
const result = await solver.altcha(PAGE_URL, { challengeJson: issueChallenge() });

console.log('Captcha ID:', result.captchaId);
console.log('Number: ', result.number);
console.log('Token: ', `${result.token.slice(0, 60)}...`);

// --- Option B: let CapSkip fetch the challenge ------------------------------
// Point it at the endpoint the widget calls. Add `proxy` if the endpoint
// should be fetched from a particular IP -- the proxy is used only for that
// fetch, never for the solve itself.
//
// const result = await solver.altcha(PAGE_URL, {
// challengeUrl: 'https://example.com/captcha/api/altcha/challenge',
// proxy: { type: 'HTTP', uri: 'login:password@1.2.3.4:8080' },
// });

// Post the token back in the form field the widget uses, named `altcha`:
//
// await fetch(SIGNUP_URL, {
// method: 'POST',
// body: new URLSearchParams({
// email: 'someone@example.com',
// altcha: result.token,
// }),
// });
//
// Do not re-encode, trim or re-order it. The token is base64 of a JSON
// document whose fields are covered by the server's HMAC signature, so any
// modification invalidates it.

// `code` holds the same string as `token`, which is what you forward if you
// are porting code written against another solver's API.
console.assert(result.code === result.token);
})().catch((err) => {
console.error(err);
process.exit(1);
});
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "capskip",
"version": "1.1.0",
"version": "1.2.0",
"description": "Captcha solver for Node.js: solve reCAPTCHA v2/v3, Cloudflare Turnstile, GeeTest and image captchas with CapSkip, a local unlimited captcha solver with no per-solve fees.",
"main": "src/index.js",
"types": "types/index.d.ts",
Expand Down
Loading
Loading