From b2c25e0a1e2a586a6cd1356db699b9d23bc5e329 Mon Sep 17 00:00:00 2001 From: capskip Date: Sun, 26 Jul 2026 02:07:42 +0800 Subject: [PATCH 1/2] Add GeeTest v3 support (v1.1.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds geetest(gt, challenge, url) to both CapSkip and AsyncCapSkip, wrapping CapSkip's method=geetest endpoint. The answer comes back as a JSON string in `request`. It stays verbatim in `code`, so code ported from another solver's API keeps working, and is also expanded into `challenge`, `validate`, and `seccode` for direct use — unlike every other captcha type, `code` alone is not directly usable here. An unparseable payload is passed through untouched rather than masked. Submit params are limited to the documented set (gt, challenge, pageurl, api_server, json, proxy, proxytype); all three required fields are checked locally so a missing value fails before the round-trip. GeeTest uses the longer recaptchaTimeout budget rather than defaultTimeout, since it is a real browser solve with internal retries. A caller-supplied timeout still wins. Also validates proxytype against the values CapSkip actually maps (HTTP, HTTPS, SOCKS5, SOCKS5H, case-insensitive) for every proxy-capable captcha type. SOCKS4 and other values previously reached the server and returned ERROR_BAD_PARAMETERS; they now raise ValidationException locally. Image captcha is excluded so it keeps its clearer "proxy not supported" message. Verified end-to-end against a live CapSkip instance: GeeTest solves and returns all three fields, with image captcha, reCAPTCHA v2, and Turnstile unaffected. --- CHANGELOG.md | 15 ++++ README.md | 24 +++++- capskip/__init__.py | 2 +- capskip/_api_params.py | 45 ++++++++++ capskip/async_solver.py | 28 ++++++ capskip/solver.py | 59 +++++++++++++ docs/API_REFERENCE.md | 77 ++++++++++++++++- docs/GETTING_STARTED.md | 1 + docs/TROUBLESHOOTING.md | 44 ++++++++++ docs/TUTORIAL.md | 118 ++++++++++++++++++++----- examples/geetest.py | 65 ++++++++++++++ pyproject.toml | 3 +- tests/test_async_geetest.py | 111 ++++++++++++++++++++++++ tests/test_geetest.py | 168 ++++++++++++++++++++++++++++++++++++ 14 files changed, 734 insertions(+), 26 deletions(-) create mode 100644 examples/geetest.py create mode 100644 tests/test_async_geetest.py create mode 100644 tests/test_geetest.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 25996ac..e67f2b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 9d65cad..78d7679 100644 --- a/README.md +++ b/README.md @@ -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)` | --- @@ -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) ) ``` @@ -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 @@ -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 diff --git a/capskip/__init__.py b/capskip/__init__.py index 9d3b1f5..a306c76 100644 --- a/capskip/__init__.py +++ b/capskip/__init__.py @@ -26,4 +26,4 @@ 'TimeoutException', ] -__version__ = '1.0.2' +__version__ = '1.1.0' diff --git a/capskip/_api_params.py b/capskip/_api_params.py index a35849d..567a1df 100644 --- a/capskip/_api_params.py +++ b/capskip/_api_params.py @@ -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', } @@ -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) @@ -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 diff --git a/capskip/async_solver.py b/capskip/async_solver.py index 9195d42..b8d1399 100644 --- a/capskip/async_solver.py +++ b/capskip/async_solver.py @@ -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, @@ -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) @@ -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)) diff --git a/capskip/solver.py b/capskip/solver.py index 5b41d09..9896e21 100644 --- a/capskip/solver.py +++ b/capskip/solver.py @@ -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| by default, or {"status":1,"request":""} # when the submit carried json=1. Accept both so submitting with json=1 works. @@ -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) @@ -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)) diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index 68be2e2..fb7ef12 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -9,7 +9,7 @@ POST http://:/in.php → submit captcha GET http://:/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. --- @@ -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. --- @@ -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: @@ -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 @@ -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 diff --git a/docs/GETTING_STARTED.md b/docs/GETTING_STARTED.md index b2fc92f..3328e5b 100644 --- a/docs/GETTING_STARTED.md +++ b/docs/GETTING_STARTED.md @@ -150,6 +150,7 @@ python examples/recaptcha.py | `image_captcha.py` | Image captcha from file, URL, or base64 | | `recaptcha.py` | reCAPTCHA v2, v3, invisible, enterprise, proxy | | `turnstile.py` | Cloudflare Turnstile widget and challenge page | +| `geetest.py` | GeeTest v3 slider, including fetching a fresh `gt`/`challenge` pair | | `async_example.py` | Parallel async solving | | `verify_connection.py` | Check CapSkip is running | diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 28daa4c..4b9f4f5 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -154,6 +154,50 @@ result = solver.recaptcha(sitekey="...", url="...", proxy=proxy) --- +## GeeTest keeps failing with a bad-challenge error + +**Symptom:** `geetest()` raises `ApiException` (or times out) even though `gt` and +`challenge` were copied correctly from the page. + +**Cause:** The `challenge` value is **single-use and expires in about a minute**. +A pair copied out of DevTools minutes earlier, cached in a config file, or reused +across two solves is already dead. + +**Fix:** Fetch a fresh pair programmatically immediately before each solve, and on +failure request a *new* pair rather than retrying the old one: + +```python +import requests + +data = requests.get("https://example.com/captcha/register.php").json() +result = solver.geetest(gt=data["gt"], challenge=data["challenge"], url=PAGE_URL) +``` + +If the site loads GeeTest from a non-default API server domain, pass it through as well: + +```python +result = solver.geetest(gt=..., challenge=..., url=..., api_server="api-na.geetest.com") +``` + +--- + +## GeeTest result — where are challenge/validate/seccode? + +`result["code"]` holds the raw JSON string CapSkip returns. The SDK also parses it +for you, so prefer the individual fields: + +```python +result["challenge"] # geetest_challenge +result["validate"] # geetest_validate +result["seccode"] # geetest_seccode +``` + +Submit all three under their `geetest_`-prefixed names, exactly as the site's own +front-end does. Sending only `validate` is the most common reason a correct solve +is rejected. + +--- + ## NetworkException during manual polling **Symptom:** `get_result()` keeps raising `NetworkException`. diff --git a/docs/TUTORIAL.md b/docs/TUTORIAL.md index 9d7c586..9c21a1e 100644 --- a/docs/TUTORIAL.md +++ b/docs/TUTORIAL.md @@ -13,14 +13,15 @@ or jump to the section you need. 5. [reCAPTCHA v2](#5-recaptcha-v2) 6. [reCAPTCHA v3](#6-recaptcha-v3) 7. [Cloudflare Turnstile](#7-cloudflare-turnstile) -8. [Using a proxy](#8-using-a-proxy) -9. [Async and concurrency](#9-async-and-concurrency) -10. [The manual workflow](#10-the-manual-workflow) -11. [Return values](#11-return-values) -12. [Error handling](#12-error-handling) -13. [End-to-end: solve and submit](#13-end-to-end-solve-and-submit) -14. [Parameter reference](#14-parameter-reference) -15. [Best practices](#15-best-practices) +8. [GeeTest v3](#8-geetest-v3) +9. [Using a proxy](#9-using-a-proxy) +10. [Async and concurrency](#10-async-and-concurrency) +11. [The manual workflow](#11-the-manual-workflow) +12. [Return values](#12-return-values) +13. [Error handling](#13-error-handling) +14. [End-to-end: solve and submit](#14-end-to-end-solve-and-submit) +15. [Parameter reference](#15-parameter-reference) +16. [Best practices](#16-best-practices) --- @@ -150,7 +151,7 @@ result = solver.normal("captcha.png", json=1) ``` > **Note:** Proxies are **not** supported for image captcha — passing one raises -> `ValidationException`. Proxies apply only to reCAPTCHA and Turnstile. +> `ValidationException`. Proxies apply only to reCAPTCHA, Turnstile, and GeeTest. --- @@ -241,24 +242,96 @@ result = solver.turnstile( --- -## 8. Using a proxy +## 8. GeeTest v3 + +`solver.geetest(gt, challenge, url)` solves the GeeTest v3 slide puzzle. + +### Finding `gt` and `challenge` + +Unlike a sitekey, GeeTest needs **two** values, and one of them is short-lived: + +| Value | Lifetime | Where it comes from | +|---|---|---| +| `gt` | Static per site | The same place as `challenge` | +| `challenge` | **Single-use, expires in ~1 minute** | An endpoint the site calls that returns `{"gt": "...", "challenge": "..."}` | + +Open DevTools → Network on the target page and look for a request to something like +`.../register.php`, `gettype`, or `get.php`. You can also read the values out of the +`initGeetest({ gt, challenge })` call in the page scripts. + +```python +import requests + +# Ask the target site for a fresh pair immediately before solving. +data = requests.get("https://example.com/captcha/register.php").json() + +result = solver.geetest( + gt=data["gt"], + challenge=data["challenge"], + url="https://example.com/login", +) +``` + +### Using the answer + +The result carries the three values the site's own front-end would submit: + +```python +result["challenge"] # geetest_challenge +result["validate"] # geetest_validate +result["seccode"] # geetest_seccode + +result["code"] # the same answer as a raw JSON string +``` + +Post them back exactly as the site expects: + +```python +requests.post("https://example.com/login", data={ + "username": "...", + "password": "...", + "geetest_challenge": result["challenge"], + "geetest_validate": result["validate"], + "geetest_seccode": result["seccode"], +}) +``` + +> **`challenge` is one-shot.** Never cache or reuse a pair. If a solve fails with a +> bad-challenge error, request a *new* pair and retry — retrying with the same +> `challenge` can never succeed. + +If the site uses a non-default GeeTest API server domain, pass it through: + +```python +result = solver.geetest(gt=..., challenge=..., url=..., api_server="api-na.geetest.com") +``` + +Because GeeTest is a real browser solve (load, slide, verify), it uses the longer +`recaptchaTimeout` budget rather than `defaultTimeout`. + +--- + +## 9. Using a proxy Solving through the same IP you will submit from greatly improves acceptance rates -for reCAPTCHA and Turnstile. Pass the proxy as a dict with `type` and `uri`: +for reCAPTCHA, Turnstile, and GeeTest. Pass the proxy as a dict with `type` and `uri`: ```python proxy = {"type": "HTTPS", "uri": "user:pass@1.2.3.4:3128"} result = solver.recaptcha(sitekey="...", url="https://example.com", proxy=proxy) result = solver.turnstile(sitekey="...", url="https://example.com", proxy=proxy) +result = solver.geetest(gt="...", challenge="...", url="https://example.com", proxy=proxy) ``` -Supported proxy types: `HTTP`, `HTTPS`, `SOCKS5`, `SOCKS5H`. The `uri` may include +Supported proxy types: `HTTP`, `HTTPS`, `SOCKS5`, `SOCKS5H` — matched +case-insensitively. Anything else (including `SOCKS4`) raises +`ValidationException` before the request is sent. The `uri` may include credentials (`login:password@host:port`) or be a bare `host:port`. --- -## 9. Async and concurrency +## 10. Async and concurrency `AsyncCapSkip` has the exact same method names as `CapSkip`, but every solve method is a coroutine. This lets you solve many captchas concurrently. @@ -290,7 +363,7 @@ results = await asyncio.gather(task1, task2, return_exceptions=True) --- -## 10. The manual workflow +## 11. The manual workflow If you want to submit now and collect the answer later, use the two low-level steps directly. @@ -322,7 +395,7 @@ Turnstile) instead of a plain string. --- -## 11. Return values +## 12. Return values Every high-level solve method (`normal`, `recaptcha`, `turnstile`, `solve`) returns a dictionary: @@ -340,7 +413,7 @@ solution string (or a dict when `json=1`). --- -## 12. Error handling +## 13. Error handling The SDK raises four exception types, all subclasses of `CapSkipError`: @@ -384,7 +457,7 @@ except CapSkipError as e: --- -## 13. End-to-end: solve and submit +## 14. End-to-end: solve and submit A realistic flow — solve a reCAPTCHA, then submit the token to the target site through the **same** proxy: @@ -436,7 +509,7 @@ requests.post(CHALLENGE_URL, data={"cf-turnstile-response": solved["code"]}, hea --- -## 14. Parameter reference +## 15. Parameter reference ### Solve methods @@ -445,6 +518,7 @@ requests.post(CHALLENGE_URL, data={"cf-turnstile-response": solved["code"]}, hea | Image | `normal(file, json=0)` | | reCAPTCHA | `recaptcha(sitekey, url, version="v2", enterprise=0, **kwargs)` | | Turnstile | `turnstile(sitekey, url, **kwargs)` | +| GeeTest v3 | `geetest(gt, challenge, url, **kwargs)` | | Manual submit | `send(**kwargs) -> id` | | Manual poll | `get_result(id, json=0)` | @@ -457,6 +531,7 @@ The SDK accepts friendly names and converts them to the raw API parameters: | `url` | `pageurl` | | `score`, `minScore` | `min_score` | | `datas`, `data_s` | `data-s` | +| `apiServer`, `api_subdomain` | `api_server` | | `proxy` (dict) | `proxy` + `proxytype` strings | Anything CapSkip does not document for a given captcha type is rejected with @@ -464,14 +539,17 @@ Anything CapSkip does not document for a given captcha type is rejected with --- -## 15. Best practices +## 16. Best practices - **Keep CapSkip running.** The SDK talks to a local app; if it is not running you get `NetworkException`. - **Use the token immediately.** reCAPTCHA and Turnstile tokens expire within a couple of minutes. - **Match sitekey and pageurl exactly** to the page the widget loads on. -- **Solve and submit from the same IP** (same proxy) for reCAPTCHA and Turnstile. +- **Fetch a fresh GeeTest `challenge` per solve.** It is single-use and expires in + about a minute; a cached pair always fails. +- **Solve and submit from the same IP** (same proxy) for reCAPTCHA, Turnstile, and + GeeTest. - **Never commit secrets.** Read `CAPSKIP_API_KEY` and proxy credentials from the environment, not source code. - **Tune timeouts** for slow captcha types with `recaptchaTimeout` and diff --git a/examples/geetest.py b/examples/geetest.py new file mode 100644 index 0000000..df81c73 --- /dev/null +++ b/examples/geetest.py @@ -0,0 +1,65 @@ +"""Solve a GeeTest v3 slider. + +GeeTest v3 needs two values from the target site: + + * ``gt`` - static per site + * ``challenge`` - single-use, expires in about a minute + +The site fetches them itself from an endpoint that returns +``{"gt": "...", "challenge": "..."}`` (often ``.../register.php`` or a +``gettype``/``get.php`` request). Open DevTools -> Network to find that request, +then request a *fresh* pair right before solving, as this example does. +""" + +import json +import os + +import requests + +from capskip import CapSkip + +solver = CapSkip( + apiKey=os.getenv('CAPSKIP_API_KEY', 'capskip'), + host=os.getenv('CAPSKIP_HOST', '127.0.0.1'), + port=int(os.getenv('CAPSKIP_PORT', '8080')), +) + +# A public GeeTest v3 demo page, and the endpoint that page calls to issue a +# fresh gt/challenge pair. Safe to run as-is. +PAGE_URL = 'https://2captcha.com/demo/geetest' +REGISTER_URL = 'https://2captcha.com/api/v1/captcha-demo/gee-test/init-params' + + +def fetch_challenge(): + """Get a fresh gt/challenge pair. Replace with the endpoint your target uses.""" + resp = requests.get(REGISTER_URL, timeout=30) + resp.raise_for_status() + data = resp.json() + return data['gt'], data['challenge'] + + +gt, challenge = fetch_challenge() + +result = solver.geetest(gt=gt, challenge=challenge, url=PAGE_URL) + +print('Captcha ID:', result['captchaId']) +print('Challenge: ', result['challenge']) +print('Validate: ', result['validate']) +print('Seccode: ', result['seccode']) + +# `code` holds the same answer as a raw JSON string, which is what you forward +# if you are porting code written against another solver's API. +print('Raw code: ', result['code']) + +# Post these back exactly as the site's own front-end would, e.g.: +# +# requests.post(LOGIN_URL, data={ +# 'geetest_challenge': result['challenge'], +# 'geetest_validate': result['validate'], +# 'geetest_seccode': result['seccode'], +# }) +print('Form fields:', json.dumps({ + 'geetest_challenge': result['challenge'], + 'geetest_validate': result['validate'], + 'geetest_seccode': result['seccode'], +}, indent=2)) diff --git a/pyproject.toml b/pyproject.toml index 8412869..e8b39fb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "capskip" -version = "1.0.2" +version = "1.1.0" description = "Python SDK for the CapSkip local captcha solver" readme = "README.md" license = { text = "MIT" } @@ -15,6 +15,7 @@ keywords = [ "recaptcha", "cloudflare", "turnstile", + "geetest", "capskip", "automation", ] diff --git a/tests/test_async_geetest.py b/tests/test_async_geetest.py new file mode 100644 index 0000000..acdaea8 --- /dev/null +++ b/tests/test_async_geetest.py @@ -0,0 +1,111 @@ +import json + +import pytest + +try: + from .abstract_async import make_solver +except ImportError: + from abstract_async import make_solver + +from capskip.exceptions import ValidationException + +GT = '81388ea1fc187e0c335c0a8907ff2625' +CHALLENGE = '7cf6a8b1a2c34d5e6f7089abcdef0123' +URL = 'https://mysite.com/page/with/geetest' + +SOLUTION = { + 'geetest_challenge': CHALLENGE, + 'geetest_validate': '9b1f4a2c8e7d6b5a4938271605f4e3d2', + 'geetest_seccode': '9b1f4a2c8e7d6b5a4938271605f4e3d2|jordan', +} + + +class AsyncGeeTestApiClient(): + """Mock async client returning a realistic GeeTest v3 answer.""" + + async def in_(self, files={}, **kwargs): + self.incomings = kwargs + self.incoming_files = files + return 'OK|123' + + async def res(self, **kwargs): + payload = json.dumps(SOLUTION) + if kwargs.get('json') in (1, '1'): + return json.dumps({'status': 1, 'request': payload}) + return 'OK|' + payload + + +def make_geetest_solver(): + solver = make_solver() + solver.api_client = AsyncGeeTestApiClient() + return solver + + +@pytest.mark.asyncio +async def test_basic(): + solver = make_geetest_solver() + + result = await solver.geetest(gt=GT, challenge=CHALLENGE, url=URL) + + assert solver.api_client.incomings == { + 'key': 'API_KEY', + 'method': 'geetest', + 'gt': GT, + 'challenge': CHALLENGE, + 'pageurl': URL, + } + assert result['captchaId'] == '123' + + +@pytest.mark.asyncio +async def test_api_server(): + solver = make_geetest_solver() + + await solver.geetest( + gt=GT, challenge=CHALLENGE, url=URL, api_server='api-na.geetest.com' + ) + + assert solver.api_client.incomings['api_server'] == 'api-na.geetest.com' + + +@pytest.mark.asyncio +async def test_proxy(): + solver = make_geetest_solver() + + await solver.geetest( + gt=GT, challenge=CHALLENGE, url=URL, + proxy={'type': 'HTTP', 'uri': '1.2.3.4:3128'}, + ) + + assert solver.api_client.incomings['proxy'] == '1.2.3.4:3128' + assert solver.api_client.incomings['proxytype'] == 'HTTP' + + +@pytest.mark.asyncio +async def test_expands_solution_fields(): + solver = make_geetest_solver() + + result = await solver.geetest(gt=GT, challenge=CHALLENGE, url=URL) + + assert json.loads(result['code']) == SOLUTION + assert result['challenge'] == SOLUTION['geetest_challenge'] + assert result['validate'] == SOLUTION['geetest_validate'] + assert result['seccode'] == SOLUTION['geetest_seccode'] + + +@pytest.mark.asyncio +async def test_missing_challenge_raises(): + solver = make_geetest_solver() + + with pytest.raises(ValidationException): + await solver.geetest(gt=GT, challenge='', url=URL) + + +@pytest.mark.asyncio +async def test_unsupported_parameter_raises(): + solver = make_geetest_solver() + + with pytest.raises(ValidationException): + await solver.geetest( + gt=GT, challenge=CHALLENGE, url=URL, sitekey='not-a-geetest-param' + ) diff --git a/tests/test_geetest.py b/tests/test_geetest.py new file mode 100644 index 0000000..ef9f010 --- /dev/null +++ b/tests/test_geetest.py @@ -0,0 +1,168 @@ +import json +import unittest + +try: + from .abstract import AbstractTest +except ImportError: + from abstract import AbstractTest + +from capskip.exceptions import ValidationException + +GT = '81388ea1fc187e0c335c0a8907ff2625' +CHALLENGE = '7cf6a8b1a2c34d5e6f7089abcdef0123' +URL = 'https://mysite.com/page/with/geetest' + +SOLUTION = { + 'geetest_challenge': CHALLENGE, + 'geetest_validate': '9b1f4a2c8e7d6b5a4938271605f4e3d2', + 'geetest_seccode': '9b1f4a2c8e7d6b5a4938271605f4e3d2|jordan', +} + + +class GeeTestApiClient(): + """Mock client returning a realistic GeeTest v3 answer (JSON string in `request`).""" + + def in_(self, files={}, **kwargs): + self.incomings = kwargs + self.incoming_files = files + return 'OK|123' + + def res(self, **kwargs): + payload = json.dumps(SOLUTION) + if kwargs.get('json') in (1, '1'): + return json.dumps({'status': 1, 'request': payload}) + return 'OK|' + payload + + +class GeeTestTest(AbstractTest): + + def setUp(self): + super().setUp() + self.solver.api_client = GeeTestApiClient() + + def solve(self, **kwargs): + params = {'gt': GT, 'challenge': CHALLENGE, 'url': URL} + params.update(kwargs) + return self.solver.geetest(**params) + + def assert_sent(self, expected): + expected.update({'key': 'API_KEY'}) + self.assertEqual(self.solver.api_client.incomings, expected) + + def test_basic(self): + result = self.solve() + + self.assert_sent({ + 'method': 'geetest', + 'gt': GT, + 'challenge': CHALLENGE, + 'pageurl': URL, + }) + self.assertEqual(result['captchaId'], '123') + + def test_api_server(self): + self.solve(api_server='api-na.geetest.com') + + self.assert_sent({ + 'method': 'geetest', + 'gt': GT, + 'challenge': CHALLENGE, + 'pageurl': URL, + 'api_server': 'api-na.geetest.com', + }) + + def test_api_server_camel_case_alias(self): + self.solve(apiServer='api-na.geetest.com') + + self.assert_sent({ + 'method': 'geetest', + 'gt': GT, + 'challenge': CHALLENGE, + 'pageurl': URL, + 'api_server': 'api-na.geetest.com', + }) + + def test_proxy(self): + self.solve(proxy={'type': 'HTTP', 'uri': '1.2.3.4:3128'}) + + self.assert_sent({ + 'method': 'geetest', + 'gt': GT, + 'challenge': CHALLENGE, + 'pageurl': URL, + 'proxy': '1.2.3.4:3128', + 'proxytype': 'HTTP', + }) + + def test_returns_raw_json_in_code(self): + # Kept verbatim so callers can forward it to code written against + # another solver's API. + result = self.solve() + + self.assertEqual(json.loads(result['code']), SOLUTION) + + def test_expands_solution_fields(self): + result = self.solve() + + self.assertEqual(result['challenge'], SOLUTION['geetest_challenge']) + self.assertEqual(result['validate'], SOLUTION['geetest_validate']) + self.assertEqual(result['seccode'], SOLUTION['geetest_seccode']) + + def test_non_json_code_is_left_alone(self): + class PlainClient(GeeTestApiClient): + def res(self, **kwargs): + return json.dumps({'status': 1, 'request': 'not-json'}) + + self.solver.api_client = PlainClient() + result = self.solve() + + self.assertEqual(result['code'], 'not-json') + self.assertNotIn('validate', result) + + def test_caller_timeout_overrides_the_default(self): + # GeeTest defaults to the longer recaptchaTimeout budget, but an explicit + # timeout must win rather than collide with it. + result = self.solve(timeout=42) + + self.assertEqual(result['captchaId'], '123') + self.assertNotIn('timeout', self.solver.api_client.incomings) + + def test_missing_challenge_raises(self): + with self.assertRaises(ValidationException): + self.solver.geetest(gt=GT, challenge='', url=URL) + + def test_missing_gt_raises(self): + with self.assertRaises(ValidationException): + self.solver.geetest(gt='', challenge=CHALLENGE, url=URL) + + def test_missing_url_raises(self): + # pageurl is documented as required; fail locally rather than paying a + # round-trip for ERROR_PAGEURL. + with self.assertRaises(ValidationException): + self.solver.geetest(gt=GT, challenge=CHALLENGE, url='') + + def test_unsupported_parameter_raises(self): + with self.assertRaises(ValidationException): + self.solve(sitekey='not-a-geetest-param') + + def test_accepted_proxy_types(self): + for proxytype in ('HTTP', 'HTTPS', 'SOCKS5', 'SOCKS5H', 'socks5h'): + with self.subTest(proxytype=proxytype): + self.solve(proxy={'type': proxytype, 'uri': '1.2.3.4:3128'}) + self.assertEqual( + self.solver.api_client.incomings['proxytype'], proxytype + ) + + def test_socks4_is_rejected(self): + # CapSkip maps only HTTP/HTTPS/SOCKS5/SOCKS5H and answers + # ERROR_BAD_PARAMETERS for SOCKS4, so fail before the round-trip. + with self.assertRaises(ValidationException): + self.solve(proxy={'type': 'SOCKS4', 'uri': '1.2.3.4:3128'}) + + def test_unknown_proxy_type_is_rejected(self): + with self.assertRaises(ValidationException): + self.solve(proxy='1.2.3.4:3128', proxytype='FTP') + + +if __name__ == '__main__': + unittest.main() From 35c5f627d3d67be7475678370b1d1265d6fd22a2 Mon Sep 17 00:00:00 2001 From: capskip Date: Sun, 26 Jul 2026 02:17:07 +0800 Subject: [PATCH 2/2] Drop the "no cloud service" phrasing from the README intro The sentence still says the solver runs locally with no per-solve fees; only the cloud comparison is removed. Cloudflare Turnstile references are unaffected. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 78d7679..c9dcd5f 100644 --- a/README.md +++ b/README.md @@ -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. ---