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
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,21 @@ 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.1.0] - 2026-07-26

### Added

- **GeeTest v3 (slide) support** via `geetest(gt, challenge, url, **kwargs)` on
both `CapSkip` and `AsyncCapSkip`. Accepts the optional `api_server` domain
override and the usual `proxy` parameter. The result exposes the answer as the
parsed `challenge`, `validate`, and `seccode` fields, while `code` keeps the raw
JSON string CapSkip returns.
- Parameter aliases `apiServer` and `api_subdomain` for `api_server`.
- `proxytype` is now validated against the values CapSkip accepts (`HTTP`,
`HTTPS`, `SOCKS5`, `SOCKS5H`, case-insensitive) for every proxy-capable captcha
type. `SOCKS4` and other values previously reached the server and came back as
`ERROR_BAD_PARAMETERS`; they now raise `ValidationException` locally.

## [1.0.2] - 2026-07-15

### Fixed
Expand Down
26 changes: 23 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

Official Python client for the [CapSkip](https://capskip.com) **local** captcha solver.

CapSkip runs on your machine and exposes a standard captcha-solver HTTP API (the familiar `in.php` / `res.php` endpoints). This SDK wraps that API with clean, familiar method names, so you can solve captchas locally — no cloud service and no per-solve API fees beyond your CapSkip license.
CapSkip runs on your machine and exposes a standard captcha-solver HTTP API (the familiar `in.php` / `res.php` endpoints). This SDK wraps that API with clean, familiar method names, so you can solve captchas locally — no per-solve API fees beyond your CapSkip license.

---

Expand Down Expand Up @@ -66,6 +66,7 @@ print(result["code"]) # g-recaptcha-response token
| reCAPTCHA v3 Enterprise | `solver.recaptcha(..., version="v3", enterprise=1)` |
| Cloudflare Turnstile (widget) | `solver.turnstile(sitekey, url)` |
| Cloudflare Turnstile (challenge page) | `solver.turnstile(..., data=..., pagedata=...)` |
| GeeTest v3 (slide) | `solver.geetest(gt, challenge, url)` |

---

Expand Down Expand Up @@ -93,7 +94,7 @@ solver = CapSkip(
host="127.0.0.1", # CapSkip host
port=8080, # CapSkip port from app settings
defaultTimeout=120, # seconds — image captcha polling timeout
recaptchaTimeout=300, # seconds — reCAPTCHA / Turnstile polling timeout
recaptchaTimeout=300, # seconds — reCAPTCHA / Turnstile / GeeTest polling timeout
pollingInterval=5, # max seconds between res.php polls (starts at 0.25s, backs off to this)
)
```
Expand Down Expand Up @@ -163,7 +164,23 @@ result = solver.turnstile(
)
```

### With a proxy (reCAPTCHA & Turnstile only)
### GeeTest v3

`gt` is static per site, but `challenge` is single-use and expires in about a
minute — fetch a fresh pair right before solving.

```python
result = solver.geetest(
gt="81388ea1fc187e0c335c0a8907ff2625",
challenge="7cf6a8b1a2c34d5e6f7089abcdef0123",
url="https://example.com/login",
)

# Post these back exactly as the site's own front-end would
result["challenge"], result["validate"], result["seccode"]
```

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

```python
# Proxy is not supported for image captcha
Expand Down Expand Up @@ -212,6 +229,9 @@ Every solve method returns:
}
```

GeeTest additionally expands its answer into `challenge`, `validate`, and
`seccode`, while `code` keeps the raw JSON string.

---

## Error handling
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.0.2'
__version__ = '1.1.0'
45 changes: 45 additions & 0 deletions capskip/_api_params.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,24 @@
'proxy', 'proxytype',
})

GEETEST_SUBMIT = frozenset({
'method', 'gt', 'challenge', 'pageurl', 'api_server', '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.
PROXY_TYPES = ('HTTP', 'HTTPS', 'SOCKS5', 'SOCKS5H')

_PARAM_ALIASES = {
'url': 'pageurl',
'score': 'min_score',
'minScore': 'min_score',
'datas': 'data-s',
'data_s': 'data-s',
'apiServer': 'api_server',
'api_subdomain': 'api_server',
}


Expand Down Expand Up @@ -104,6 +116,33 @@ def validate_turnstile_submit(params: dict) -> None:
)


def validate_geetest_submit(params: dict) -> None:
# All three are documented as required. gt is static per site, challenge is
# single-use and expires in about a minute; without them CapSkip answers
# ERROR_BAD_PARAMETERS, and without pageurl ERROR_PAGEURL. Fail locally so a
# missing value does not cost a round-trip.
for key in ('gt', 'challenge', 'pageurl'):
if not params.get(key):
raise ValidationException(f"{key!r} is required for GeeTest v3.")

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


def validate_proxy_type(params: dict) -> None:
proxytype = params.get('proxytype')
if proxytype in (None, ''):
return
if str(proxytype).upper() not in PROXY_TYPES:
raise ValidationException(
f"Unsupported proxytype {proxytype!r}. "
f"CapSkip accepts: {', '.join(PROXY_TYPES)}."
)


def prepare_submit_params(params: dict, captcha_type: str, version: str = 'v2') -> dict:
params = apply_param_aliases(params)
params = apply_proxy(params)
Expand All @@ -114,5 +153,11 @@ def prepare_submit_params(params: dict, captcha_type: str, version: str = 'v2')
validate_recaptcha_submit(params, version)
elif captcha_type == 'turnstile':
validate_turnstile_submit(params)
elif captcha_type == 'geetest':
validate_geetest_submit(params)

# Skipped for 'normal', which rejects proxy outright with a clearer message.
if captcha_type != 'normal':
validate_proxy_type(params)

return params
28 changes: 28 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_geetest_solution,
_apply_poll_result,
_next_poll_interval,
_parse_poll_response,
Expand Down Expand Up @@ -66,6 +67,31 @@ async def turnstile(self, sitekey, url, **kwargs):
**kwargs,
)

async def geetest(self, gt, challenge, url, **kwargs):
"""Solve a GeeTest v3 slider.

`gt` is static per site; `challenge` is single-use and expires in about a
minute, so fetch a fresh pair immediately before calling this. Pass
`api_server` when the site uses a non-default GeeTest API server domain.

The result carries the raw answer as `code` (a JSON string) plus the
parsed `challenge`, `validate`, and `seccode` fields to post back to the
target site.
"""
params = {
'gt': gt,
'challenge': challenge,
'url': url,
'method': 'geetest',
'poll_json': 1,
**kwargs,
}
# Like reCAPTCHA, this is a real browser solve (load, slide, verify) and
# can retry internally, so it gets the longer of the two timeouts unless
# the caller asked for a specific one.
params.setdefault('timeout', self.recaptcha_timeout)
return _apply_geetest_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 @@ -124,4 +150,6 @@ def _prepare_send_params(self, params: dict) -> dict:
return prepare_submit_params(params, 'recaptcha', params.get('version', 'v2'))
if method == 'turnstile':
return prepare_submit_params(params, 'turnstile')
if method == 'geetest':
return prepare_submit_params(params, 'geetest')
return apply_proxy(apply_param_aliases(params))
59 changes: 59 additions & 0 deletions capskip/solver.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,38 @@ def _apply_poll_result(result: dict, polled) -> dict:
return result


# GeeTest answers come back as a JSON string in `request`, keyed with the
# geetest_ prefix that the target site's own form fields use.
_GEETEST_FIELDS = (
('challenge', 'geetest_challenge'),
('validate', 'geetest_validate'),
('seccode', 'geetest_seccode'),
)


def _apply_geetest_solution(result: dict) -> dict:
"""Expand the GeeTest answer into `challenge` / `validate` / `seccode`.

`code` keeps the raw JSON string so callers that forward it verbatim (or that
were written against another solver's API) keep working. If it does not parse,
the result is returned untouched rather than masking the server's reply.
"""
try:
payload = json.loads(result.get('code') or '')
except (json.JSONDecodeError, TypeError):
return result

if not isinstance(payload, dict):
return result

for short, prefixed in _GEETEST_FIELDS:
value = payload.get(prefixed, payload.get(short))
if value is not None:
result[short] = value

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 @@ -130,6 +162,31 @@ def turnstile(self, sitekey, url, **kwargs):
**kwargs,
)

def geetest(self, gt, challenge, url, **kwargs):
"""Solve a GeeTest v3 slider.

`gt` is static per site; `challenge` is single-use and expires in about a
minute, so fetch a fresh pair immediately before calling this. Pass
`api_server` when the site uses a non-default GeeTest API server domain.

The result carries the raw answer as `code` (a JSON string) plus the
parsed `challenge`, `validate`, and `seccode` fields to post back to the
target site.
"""
params = {
'gt': gt,
'challenge': challenge,
'url': url,
'method': 'geetest',
'poll_json': 1,
**kwargs,
}
# Like reCAPTCHA, this is a real browser solve (load, slide, verify) and
# can retry internally, so it gets the longer of the two timeouts unless
# the caller asked for a specific one.
params.setdefault('timeout', self.recaptcha_timeout)
return _apply_geetest_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 @@ -186,4 +243,6 @@ def _prepare_send_params(self, params: dict) -> dict:
return prepare_submit_params(params, 'recaptcha', params.get('version', 'v2'))
if method == 'turnstile':
return prepare_submit_params(params, 'turnstile')
if method == 'geetest':
return prepare_submit_params(params, 'geetest')
return apply_proxy(apply_param_aliases(params))
77 changes: 75 additions & 2 deletions docs/API_REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ POST http://<host>:<port>/in.php → submit captcha
GET http://<host>:<port>/res.php → poll result
```

The SDK only supports the four captcha types documented by CapSkip.
The SDK only supports the captcha types documented by CapSkip.

---

Expand All @@ -21,8 +21,9 @@ The SDK only supports the four captcha types documented by CapSkip.
| reCAPTCHA v2 | `recaptcha(..., version='v2')` | `userrecaptcha` |
| reCAPTCHA v3 | `recaptcha(..., version='v3')` | `userrecaptcha` + `version=v3` |
| Cloudflare Turnstile | `turnstile()` | `turnstile` |
| GeeTest v3 (slide) | `geetest()` | `geetest` |

**Proxy** is supported for reCAPTCHA and Turnstile only — not for image captcha.
**Proxy** is supported for reCAPTCHA, Turnstile, and GeeTest — not for image captcha.

---

Expand Down Expand Up @@ -209,6 +210,63 @@ result = solver.turnstile(

---

## 5. GeeTest v3 — `geetest(gt, challenge, url, ...)`

### POST `/in.php`

| Parameter | Type | Required | Description |
|---|---|---|---|
| `key` | string | Yes | CapSkip API key |
| `method` | string | Yes | `geetest` |
| `gt` | string | Yes | Static per-site GeeTest id |
| `challenge` | string | Yes | Single-use challenge token |
| `pageurl` | string | Yes | Full page URL |
| `api_server` | string | No | GeeTest API server domain, e.g. `api-na.geetest.com` |
| `json` | int | No | `0` plain text, `1` JSON |
| `proxy` | string | No | Proxy address |
| `proxytype` | string | No | Proxy type |

### Getting `gt` and `challenge`

Both come from the target site, which fetches them from an endpoint returning
`{"gt": "...", "challenge": "..."}` (often `.../register.php` or a `gettype`/`get.php`
request). Find it in DevTools → Network, or read them out of the
`initGeetest({ gt, challenge })` call in the page scripts.

> **`challenge` is single-use and expires in about a minute.** Fetch a fresh pair
> immediately before each solve. If a solve comes back with a bad-challenge error,
> request a new pair and retry — reusing one never succeeds.

### SDK usage

```python
result = solver.geetest(
gt="81388ea1fc187e0c335c0a8907ff2625",
challenge="7cf6a8b1a2c34d5e6f7089abcdef0123",
url="https://example.com/login",
)

result["challenge"] # geetest_challenge
result["validate"] # geetest_validate
result["seccode"] # geetest_seccode
result["code"] # the same answer as a raw JSON string
```

Post the three fields back exactly as the site's own front-end would:

```python
requests.post(LOGIN_URL, data={
"geetest_challenge": result["challenge"],
"geetest_validate": result["validate"],
"geetest_seccode": result["seccode"],
})
```

GeeTest is a real browser solve, so it uses the longer `recaptchaTimeout` budget
rather than `defaultTimeout`.

---

## Return value

Every solve method returns:
Expand All @@ -221,6 +279,19 @@ Every solve method returns:
}
```

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

```python
{
"captchaId": "12345",
"code": '{"geetest_challenge":"...","geetest_validate":"...","geetest_seccode":"..."}',
"challenge": "...",
"validate": "...",
"seccode": "...",
}
```

---

## SDK parameter aliases
Expand All @@ -234,6 +305,8 @@ Convenience aliases mapped before sending to CapSkip:
| `minScore` | `min_score` |
| `datas` | `data-s` |
| `data_s` | `data-s` |
| `apiServer` | `api_server` |
| `api_subdomain` | `api_server` |
| `proxy` dict | `proxy` + `proxytype` strings |

```python
Expand Down
Loading
Loading