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
27 changes: 27 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,33 @@ 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, **kwargs)` on both `CapSkip` and
`AsyncCapSkip`. Pass `challenge_url` for CapSkip to fetch the challenge, or
`challenge_json` with the document itself (a JSON string, or a `dict` 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`.
- 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 `challenge_url` fetch; a task
carrying its challenge inline never touches the network.

## [1.1.0] - 2026-07-26

### Added
Expand Down
34 changes: 28 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-python/actions/workflows/ci.yml/badge.svg)](https://github.com/capskip/capskip-python/actions/workflows/ci.yml)

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

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

Expand All @@ -19,7 +19,7 @@ pip 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(..., data=..., pagedata=...)` |
| **GeeTest v3 solver** (slide puzzle) | `solver.geetest(gt, challenge, url)` |
| **ALTCHA solver** (proof-of-work) | `solver.altcha(url, challenge_url=...)` |

**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 @@ -127,6 +128,7 @@ solver = 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 — it is 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 @@ -212,7 +214,26 @@ result = 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.

```python
result = solver.altcha(
url="https://example.com/signup",
challenge_url="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)

```python
# Proxy is not supported for image captcha
Expand Down Expand Up @@ -300,7 +321,8 @@ Every solve method returns:
```

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 All @@ -327,15 +349,15 @@ except TimeoutException:

### How do I solve a captcha in Python?

Install the CapSkip desktop app, `pip install capskip`, then call the method that matches the widget — `recaptcha()`, `turnstile()`, `geetest()` or `normal()`. Each one polls until CapSkip has an answer, then returns a token, or the recognized text in the case of an image captcha.
Install the CapSkip desktop app, `pip install capskip`, then call the method that matches the widget — `recaptcha()`, `turnstile()`, `geetest()`, `altcha()` or `normal()`. Each one polls until CapSkip has an answer, then returns 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 Selenium and Playwright?

Expand Down
2 changes: 1 addition & 1 deletion capskip/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,4 @@
'TimeoutException',
]

__version__ = '1.1.0'
__version__ = '1.2.0'
51 changes: 51 additions & 0 deletions capskip/_api_params.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
"""CapSkip API parameter validation (https://capskip.com/api-docs/)."""

import json

from .exceptions import ValidationException

NORMAL_SUBMIT = frozenset({'method', 'body', 'json', 'file'})
Expand All @@ -24,6 +26,11 @@
'proxy', 'proxytype',
})

ALTCHA_SUBMIT = frozenset({
'method', 'pageurl', 'challenge_url', 'challenge_json', 'json',
'proxy', 'proxytype',
})

# The only values CapSkip maps to a proxy scheme; it answers
# ERROR_BAD_PARAMETERS for anything else, SOCKS4 included. Matched
# case-insensitively, as the server does.
Expand All @@ -37,6 +44,10 @@
'data_s': 'data-s',
'apiServer': 'api_server',
'api_subdomain': 'api_server',
'challengeUrl': 'challenge_url',
'challengeURL': 'challenge_url',
'challengeJson': 'challenge_json',
'challengeJSON': 'challenge_json',
}


Expand Down Expand Up @@ -132,6 +143,43 @@ def validate_geetest_submit(params: dict) -> None:
)


def normalize_altcha_submit(params: dict) -> dict:
"""Drop unset challenge params and serialize an inline challenge document.

`altcha(url, challenge_url=a, challenge_json=b)` is normally called with one
of the two left as None, and the form body can only carry a string — so a
document passed as a dict is serialized rather than stringified into Python's
repr. Mirrors the server, which reads a JSON-body `null` as "not sent".
"""
out = {k: v for k, v in params.items() if v is not None}

challenge = out.get('challenge_json')
if isinstance(challenge, (dict, list)):
out['challenge_json'] = json.dumps(challenge)

return out


def validate_altcha_submit(params: dict) -> None:
if not params.get('pageurl'):
raise ValidationException("'pageurl' is required for ALTCHA.")

# CapSkip answers ERROR_BAD_PARAMETERS when neither is sent. Sending both is
# deliberately allowed -- the inline document simply wins, because fetching
# would only re-obtain what the caller already supplied.
if not params.get('challenge_url') and not params.get('challenge_json'):
raise ValidationException(
"ALTCHA needs a challenge: pass 'challenge_url' for CapSkip to fetch "
"it, or 'challenge_json' with the challenge document itself."
)

unknown = _unknown_keys(params, ALTCHA_SUBMIT)
if unknown:
raise ValidationException(
f"Unsupported parameters for ALTCHA: {sorted(unknown)}."
)


def validate_proxy_type(params: dict) -> None:
proxytype = params.get('proxytype')
if proxytype in (None, ''):
Expand All @@ -155,6 +203,9 @@ def prepare_submit_params(params: dict, captcha_type: str, version: str = 'v2')
validate_turnstile_submit(params)
elif captcha_type == 'geetest':
validate_geetest_submit(params)
elif captcha_type == 'altcha':
params = normalize_altcha_submit(params)
validate_altcha_submit(params)

# Skipped for 'normal', which rejects proxy outright with a clearer message.
if captcha_type != 'normal':
Expand Down
33 changes: 33 additions & 0 deletions capskip/async_solver.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from .exceptions import NetworkException, TimeoutException, ValidationException, SolverExceptions
from .solver import (
INITIAL_POLLING_INTERVAL,
_apply_altcha_solution,
_apply_geetest_solution,
_apply_poll_result,
_next_poll_interval,
Expand Down Expand Up @@ -92,6 +93,36 @@ async def geetest(self, gt, challenge, url, **kwargs):
params.setdefault('timeout', self.recaptcha_timeout)
return _apply_geetest_solution(await self.solve(**params))

async def altcha(self, url, **kwargs):
"""Solve an ALTCHA proof-of-work challenge.

Pass `challenge_url` for CapSkip to fetch the challenge itself, or
`challenge_json` with the document you already have (a JSON string, or a
dict which is serialized for you). Sending both is allowed -- the inline
document wins. A proxy applies only to the `challenge_url` fetch.

Challenges expire fast -- some sites inside two minutes -- and an expired
one is refused with a bare "verification failed" that looks exactly like
a wrong answer. Fetch the challenge immediately before calling, and post
the token promptly.

The result carries the raw answer as `code`, the same string as `token`
(what the site's `altcha` form field expects, verbatim), and the counter
that solved it as `number`.
"""
params = {
'url': url,
'method': 'altcha',
'poll_json': 1,
# An unset challenge param is dropped rather than sent as None, so
# `altcha(url, challenge_url=a, challenge_json=b)` works with either
# one left out.
**{k: v for k, v in kwargs.items() if v is not None},
}
# Unlike GeeTest and reCAPTCHA this is CPU proof-of-work measured in
# milliseconds, not a browser solve, so it keeps the default timeout.
return _apply_altcha_solution(await self.solve(**params))

async def solve(self, timeout=0, polling_interval=0, poll_json=0, **kwargs):
poll_json = int(kwargs.pop('poll_json', poll_json) or 0)
captcha_id = await self.send(**kwargs)
Expand Down Expand Up @@ -152,4 +183,6 @@ def _prepare_send_params(self, params: dict) -> dict:
return prepare_submit_params(params, 'turnstile')
if method == 'geetest':
return prepare_submit_params(params, 'geetest')
if method == 'altcha':
return prepare_submit_params(params, 'altcha')
return apply_proxy(apply_param_aliases(params))
98 changes: 97 additions & 1 deletion capskip/solver.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import json
import os
import time
from base64 import b64encode
from base64 import b64decode, b64encode

import requests

Expand Down Expand Up @@ -58,6 +58,13 @@ def _apply_poll_result(result: dict, polled) -> dict:
user_agent = polled.get('useragent') or polled.get('userAgent')
if user_agent:
result['userAgent'] = user_agent
# ALTCHA's createTask-shaped `solution` object. Carried through so
# _apply_altcha_solution can read the counter the server already worked
# out, which is the only reliable source for a proof-of-work v2 answer;
# that method pops it, so it never reaches the caller.
solution = polled.get('solution')
if isinstance(solution, dict):
result['solution'] = solution
else:
result['code'] = polled
return result
Expand Down Expand Up @@ -95,6 +102,63 @@ def _apply_geetest_solution(result: dict) -> dict:
return result


# ALTCHA answers come back as a base64 payload: the challenge document with the
# winning counter added. That payload is what the site's own `altcha` form field
# carries, so it is posted back verbatim.
def _token_counter(code: str):
"""Dig the winning counter out of a token, whichever scheme produced it.

The two ALTCHA generations nest it differently: a legacy payload is the
challenge document with a top-level `number` added, while a proof-of-work v2
payload is `{"challenge": {...}, "solution": {"counter": N, ...}}` and has no
`number` at all. Returns None if the payload does not decode.
"""
try:
payload = json.loads(b64decode(code, validate=True))
except (ValueError, TypeError):
return None

if not isinstance(payload, dict):
return None

if 'number' in payload:
return payload['number']

solution = payload.get('solution')
if isinstance(solution, dict):
return solution.get('counter')

return None


def _apply_altcha_solution(result: dict) -> dict:
"""Expose the answer as `token`, and the winning counter as `number`.

`code` keeps the raw answer so callers that forward it verbatim (or that were
written against another solver's API) keep working; `token` is the same
string, named for the form field it goes into.

The counter comes from the server's own `solution` object when the poll
carried one, because that is the single field both ALTCHA generations report
the same way. Only if it is absent -- a plain-text poll -- is it dug out of
the token, which is shaped differently per scheme. If neither yields one, the
result keeps its token and simply has no `number`, rather than masking the
server's reply.
"""
code = result.get('code') or ''
result['token'] = code

solution = result.pop('solution', None)
number = solution.get('number') if isinstance(solution, dict) else None
if number is None:
number = _token_counter(code)

if number is not None:
result['number'] = number

return result


def _parse_submit_response(response: str) -> str:
# CapSkip's in.php returns OK|<id> by default, or {"status":1,"request":"<id>"}
# when the submit carried json=1. Accept both so submitting with json=1 works.
Expand Down Expand Up @@ -187,6 +251,36 @@ def geetest(self, gt, challenge, url, **kwargs):
params.setdefault('timeout', self.recaptcha_timeout)
return _apply_geetest_solution(self.solve(**params))

def altcha(self, url, **kwargs):
"""Solve an ALTCHA proof-of-work challenge.

Pass `challenge_url` for CapSkip to fetch the challenge itself, or
`challenge_json` with the document you already have (a JSON string, or a
dict which is serialized for you). Sending both is allowed -- the inline
document wins. A proxy applies only to the `challenge_url` fetch.

Challenges expire fast -- some sites inside two minutes -- and an expired
one is refused with a bare "verification failed" that looks exactly like
a wrong answer. Fetch the challenge immediately before calling, and post
the token promptly.

The result carries the raw answer as `code`, the same string as `token`
(what the site's `altcha` form field expects, verbatim), and the counter
that solved it as `number`.
"""
params = {
'url': url,
'method': 'altcha',
'poll_json': 1,
# An unset challenge param is dropped rather than sent as None, so
# `altcha(url, challenge_url=a, challenge_json=b)` works with either
# one left out.
**{k: v for k, v in kwargs.items() if v is not None},
}
# Unlike GeeTest and reCAPTCHA this is CPU proof-of-work measured in
# milliseconds, not a browser solve, so it keeps the default timeout.
return _apply_altcha_solution(self.solve(**params))

def solve(self, timeout=0, polling_interval=0, poll_json=0, **kwargs):
poll_json = int(kwargs.pop('poll_json', poll_json) or 0)
captcha_id = self.send(**kwargs)
Expand Down Expand Up @@ -245,4 +339,6 @@ def _prepare_send_params(self, params: dict) -> dict:
return prepare_submit_params(params, 'turnstile')
if method == 'geetest':
return prepare_submit_params(params, 'geetest')
if method == 'altcha':
return prepare_submit_params(params, 'altcha')
return apply_proxy(apply_param_aliases(params))
Loading
Loading