From da1d2e3a9d8bf277ae3208ef39bf0e9429486a70 Mon Sep 17 00:00:00 2001 From: capskip Date: Sun, 26 Jul 2026 03:00:11 +0800 Subject: [PATCH] Add GeeTest v3 support (v1.1.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds geetest(string $gt, string $challenge, string $url, array $options = []) to CapSkip (and so 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. Drops the "no cloud service" phrasing from the README intro; the sentence still makes the local / no-per-solve-fee point. Verified end-to-end against a live CapSkip instance, both from source and from a composer-installed copy: GeeTest solves and returns all three fields, with image captcha, reCAPTCHA v2, and Turnstile unaffected. --- CHANGELOG.md | 15 +++ README.md | 26 ++++- composer.json | 1 + docs/API_REFERENCE.md | 54 +++++++++- docs/GETTING_STARTED.md | 1 + docs/TROUBLESHOOTING.md | 42 ++++++++ docs/TUTORIAL.md | 115 +++++++++++++++++---- examples/geetest.php | 69 +++++++++++++ src/ApiParams.php | 65 ++++++++++++ src/CapSkip.php | 71 ++++++++++++- tests/GeetestTest.php | 216 ++++++++++++++++++++++++++++++++++++++++ 11 files changed, 647 insertions(+), 28 deletions(-) create mode 100644 examples/geetest.php create mode 100644 tests/GeetestTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index a32e690..7034356 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(string $gt, string $challenge, string $url, array $options = [])` + on both `CapSkip` and `AsyncCapSkip`. Accepts the optional `api_server` domain + override and the usual `proxy` option. The result exposes the answer as the + parsed `challenge`, `validate`, and `seccode` keys, 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 ### Changed diff --git a/README.md b/README.md index b4fcb03..7b52e8f 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Official PHP 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. --- @@ -70,6 +70,7 @@ echo $result['code']; // g-recaptcha-response token | reCAPTCHA v3 Enterprise | `$solver->recaptcha($sitekey, $url, ['version' => 'v3', 'enterprise' => 1])` | | Cloudflare Turnstile (widget) | `$solver->turnstile($sitekey, $url)` | | Cloudflare Turnstile (challenge page) | `$solver->turnstile($sitekey, $url, ['data' => ..., 'pagedata' => ...])` | +| GeeTest v3 (slide) | `$solver->geetest($gt, $challenge, $url)` | --- @@ -97,7 +98,7 @@ $solver = new 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) ]); ``` @@ -161,7 +162,23 @@ $v3 = $solver->recaptcha('...', 'https://example.com', [ $result = $solver->turnstile('0x4AAAAAAA...', 'https://example.com'); ``` -### 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. + +```php +$result = $solver->geetest( + '81388ea1fc187e0c335c0a8907ff2625', + '7cf6a8b1a2c34d5e6f7089abcdef0123', + '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) ```php // Proxy is not supported for image captcha @@ -202,6 +219,9 @@ Every solve method returns an associative array: ] ``` +GeeTest additionally expands its answer into `challenge`, `validate`, and +`seccode`, while `code` keeps the raw JSON string. + --- ## Error handling diff --git a/composer.json b/composer.json index 5c69349..d365156 100644 --- a/composer.json +++ b/composer.json @@ -8,6 +8,7 @@ "recaptcha", "cloudflare", "turnstile", + "geetest", "capskip", "automation" ], diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index a32b313..017d3ed 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -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. --- @@ -203,6 +204,55 @@ $result = $solver->turnstile('0x4AAAAAAA...', 'https://example.com', [ --- +## 5. GeeTest v3 — `geetest($gt, $challenge, $url, $options)` + +### 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 + +```php +$result = $solver->geetest( + '81388ea1fc187e0c335c0a8907ff2625', + '7cf6a8b1a2c34d5e6f7089abcdef0123', + '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. + +GeeTest is a real browser solve, so it uses the longer `recaptchaTimeout` budget +rather than `defaultTimeout`. + +--- + ## Return value Every solve method returns: @@ -228,6 +278,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` array | `proxy` + `proxytype` strings | ```php diff --git a/docs/GETTING_STARTED.md b/docs/GETTING_STARTED.md index b275847..ba583ff 100644 --- a/docs/GETTING_STARTED.md +++ b/docs/GETTING_STARTED.md @@ -137,6 +137,7 @@ php examples/recaptcha.php | `image_captcha.php` | Image captcha from a file | | `recaptcha.php` | reCAPTCHA v2 | | `turnstile.php` | Cloudflare Turnstile widget | +| `geetest.php` | GeeTest v3 slider, including fetching a fresh `gt`/`challenge` pair | | `async_example.php` | Solving several captcha types in a row | | `verify_connection.php` | Check CapSkip is running | diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index ad3d4e5..d458c98 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -153,6 +153,48 @@ $result = $solver->recaptcha('...', '...', ['proxy' => $proxy]); --- +## GeeTest keeps failing with a bad-challenge error + +**Symptom:** `geetest()` throws `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: + +```php +$pair = json_decode(file_get_contents($registerUrl), true); +$result = $solver->geetest($pair['gt'], $pair['challenge'], $pageUrl); +``` + +If the site loads GeeTest from a non-default API server domain, pass it through as well: + +```php +$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: + +```php +$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:** `getResult()` keeps throwing `NetworkException`. diff --git a/docs/TUTORIAL.md b/docs/TUTORIAL.md index 1ec78eb..ffa81cb 100644 --- a/docs/TUTORIAL.md +++ b/docs/TUTORIAL.md @@ -12,14 +12,15 @@ Work through it top to bottom, 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. [Solving several captchas](#9-solving-several-captchas) -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. [Solving several captchas](#10-solving-several-captchas) +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) --- @@ -77,7 +78,7 @@ $solver = new CapSkip([ 'host' => '127.0.0.1', // where CapSkip is listening 'port' => 8080, // API port from CapSkip settings 'defaultTimeout' => 120, // seconds to wait for an image captcha - 'recaptchaTimeout' => 300, // seconds to wait for reCAPTCHA / Turnstile + 'recaptchaTimeout' => 300, // seconds to wait for reCAPTCHA / Turnstile / GeeTest 'pollingInterval' => 5, // max seconds between result polls (starts at 0.25s, backs off to this) ]); ``` @@ -146,7 +147,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. --- @@ -229,10 +230,73 @@ $result = $solver->turnstile('0x4AAAAAAA...', 'https://example.com', [ --- -## 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. + +```php +// Ask the target site for a fresh pair immediately before solving. +$pair = json_decode(file_get_contents('https://example.com/captcha/register.php'), true); + +$result = $solver->geetest($pair['gt'], $pair['challenge'], 'https://example.com/login'); +``` + +### Using the answer + +The result carries the three values the site's own front-end would submit: + +```php +$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: + +```php +$body = http_build_query([ + '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: + +```php +$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 an array with `type` and `uri`: +for reCAPTCHA, Turnstile, and GeeTest. Pass the proxy as an array with `type` and `uri`: ```php $proxy = ['type' => 'HTTPS', 'uri' => 'user:pass@1.2.3.4:3128']; @@ -241,12 +305,14 @@ $result = $solver->recaptcha('...', 'https://example.com', ['proxy' => $proxy]); $result = $solver->turnstile('...', '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. Solving several captchas +## 10. Solving several captchas PHP executes synchronously, so each solve blocks until it finishes. To solve several captchas, call the methods one after another: @@ -270,7 +336,7 @@ job queue). --- -## 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. @@ -303,7 +369,7 @@ Pass `1` as the second argument to `getResult` to get the full array (including --- -## 11. Return values +## 12. Return values Every high-level solve method (`normal`, `recaptcha`, `turnstile`, `solve`) returns an associative array: @@ -321,7 +387,7 @@ solution string (or an array when `json=1`). --- -## 12. Error handling +## 13. Error handling The SDK raises four exception types, all subclasses of `CapSkip\Exceptions\CapSkipError`: @@ -369,7 +435,7 @@ try { --- -## 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: @@ -431,7 +497,7 @@ curl_close($ch); --- -## 14. Parameter reference +## 15. Parameter reference ### Solve methods @@ -440,12 +506,13 @@ curl_close($ch); | Image | `normal(string $file, array $options = [])` | | reCAPTCHA | `recaptcha(string $sitekey, string $url, array $options = [])` | | Turnstile | `turnstile(string $sitekey, string $url, array $options = [])` | +| GeeTest v3 | `geetest(string $gt, string $challenge, string $url, array $options = [])` | | Manual submit | `send(array $params): string` | | Manual poll | `getResult(string $id, int $json = 0)` | `recaptcha` options include `version` (`v2`/`v3`), `enterprise`, `invisible`, `action`, `score`, and `proxy`. `turnstile` options include `action`, `data`, -`pagedata`, and `proxy`. +`pagedata`, and `proxy`. `geetest` options include `api_server` and `proxy`. ### Convenience aliases @@ -456,6 +523,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` (array) | `proxy` + `proxytype` strings | Anything CapSkip does not document for a given captcha type is rejected with @@ -463,14 +531,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.php b/examples/geetest.php new file mode 100644 index 0000000..fee4f05 --- /dev/null +++ b/examples/geetest.php @@ -0,0 +1,69 @@ + Network to find that request, + * then request a *fresh* pair right before solving, as this example does. + */ + +declare(strict_types=1); + +require __DIR__ . '/../vendor/autoload.php'; + +use CapSkip\CapSkip; + +$solver = new CapSkip([ + 'apiKey' => getenv('CAPSKIP_API_KEY') ?: 'capskip', + 'host' => getenv('CAPSKIP_HOST') ?: '127.0.0.1', + 'port' => (int) (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. +$pageUrl = 'https://2captcha.com/demo/geetest'; +$registerUrl = 'https://2captcha.com/api/v1/captcha-demo/gee-test/init-params'; + +/** Get a fresh gt/challenge pair. Replace with the endpoint your target uses. */ +function fetchChallenge(string $url): array +{ + $body = file_get_contents($url); + if ($body === false) { + throw new RuntimeException("Could not fetch a gt/challenge pair from {$url}"); + } + + return json_decode($body, true); +} + +$pair = fetchChallenge($registerUrl); + +$result = $solver->geetest($pair['gt'], $pair['challenge'], $pageUrl); + +echo 'Captcha ID: ' . $result['captchaId'] . PHP_EOL; +echo 'Challenge: ' . $result['challenge'] . PHP_EOL; +echo 'Validate: ' . $result['validate'] . PHP_EOL; +echo 'Seccode: ' . $result['seccode'] . PHP_EOL; + +// `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. +echo 'Raw code: ' . $result['code'] . PHP_EOL; + +// Post these back exactly as the site's own front-end would, e.g.: +// +// http_build_query([ +// 'geetest_challenge' => $result['challenge'], +// 'geetest_validate' => $result['validate'], +// 'geetest_seccode' => $result['seccode'], +// ]); +echo 'Form fields: ' . json_encode([ + 'geetest_challenge' => $result['challenge'], + 'geetest_validate' => $result['validate'], + 'geetest_seccode' => $result['seccode'], +], JSON_PRETTY_PRINT) . PHP_EOL; diff --git a/src/ApiParams.php b/src/ApiParams.php index 80f4d49..ab45e67 100644 --- a/src/ApiParams.php +++ b/src/ApiParams.php @@ -30,6 +30,21 @@ final class ApiParams 'proxy', 'proxytype', ]; + /** @var array */ + public const GEETEST_SUBMIT = [ + '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. + * + * @var array + */ + public const PROXY_TYPES = ['HTTP', 'HTTPS', 'SOCKS5', 'SOCKS5H']; + /** @var array */ private const PARAM_ALIASES = [ 'url' => 'pageurl', @@ -37,6 +52,8 @@ final class ApiParams 'minScore' => 'min_score', 'datas' => 'data-s', 'data_s' => 'data-s', + 'apiServer' => 'api_server', + 'api_subdomain' => 'api_server', ]; /** @@ -161,6 +178,29 @@ public static function validateTurnstileSubmit(array $params): void } } + /** + * @param array $params + */ + public static function validateGeetestSubmit(array $params): void + { + // 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. + foreach (['gt', 'challenge', 'pageurl'] as $key) { + if (empty($params[$key])) { + throw new ValidationException("'{$key}' is required for GeeTest v3."); + } + } + + $unknown = self::unknownKeys($params, self::GEETEST_SUBMIT); + if (!empty($unknown)) { + throw new ValidationException( + 'Unsupported parameters for GeeTest: ' . self::reprList($unknown) . '.' + ); + } + } + /** * Apply aliases + proxy normalization, then validate for the captcha type. * @@ -179,11 +219,36 @@ public static function prepareSubmitParams(array $params, string $captchaType, s self::validateRecaptchaSubmit($params, $version); } elseif ($captchaType === 'turnstile') { self::validateTurnstileSubmit($params); + } elseif ($captchaType === 'geetest') { + self::validateGeetestSubmit($params); + } + + // Skipped for 'normal', which rejects proxy outright with a clearer message. + if ($captchaType !== 'normal') { + self::validateProxyType($params); } return $params; } + /** + * @param array $params + */ + public static function validateProxyType(array $params): void + { + $proxytype = $params['proxytype'] ?? null; + if ($proxytype === null || $proxytype === '') { + return; + } + + if (!in_array(strtoupper((string) $proxytype), self::PROXY_TYPES, true)) { + throw new ValidationException( + "Unsupported proxytype '{$proxytype}'. " + . 'CapSkip accepts: ' . implode(', ', self::PROXY_TYPES) . '.' + ); + } + } + /** * Format a list the way Python's `sorted(...)` repr does, for message parity. * diff --git a/src/CapSkip.php b/src/CapSkip.php index 075f598..39a15ad 100644 --- a/src/CapSkip.php +++ b/src/CapSkip.php @@ -10,11 +10,11 @@ use CapSkip\Exceptions\TimeoutException; use CapSkip\Exceptions\ValidationException; -/** Client for the CapSkip local captcha solver (image, reCAPTCHA, Turnstile). */ +/** Client for the CapSkip local captcha solver (image, reCAPTCHA, Turnstile, GeeTest v3). */ class CapSkip { /** Installed SDK version. */ - public const VERSION = '1.0.2'; + public const VERSION = '1.1.0'; /** * First poll fires this soon after submitting (in seconds), then the interval @@ -123,6 +123,34 @@ public function turnstile(string $sitekey, string $url, array $options = []): ar )); } + /** + * 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. + * + * @param array $options `api_server`, `proxy`, ... + * + * @return array + */ + public function geetest(string $gt, string $challenge, string $url, array $options = []): array + { + // 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. + $result = $this->solve(array_merge( + ['timeout' => $this->recaptchaTimeout, 'gt' => $gt, 'challenge' => $challenge, 'url' => $url], + $options, + ['method' => 'geetest', 'poll_json' => 1] + )); + + return self::applyGeetestSolution($result); + } + /** * Submit then poll to completion. Used by the higher-level solve methods. * @@ -306,6 +334,42 @@ public static function nextPollInterval(float $interval, float $ceiling): float return min($interval * 2, $ceiling); } + /** + * GeeTest answers come back as a JSON string in `request`, keyed with the + * `geetest_` prefix that the target site's own form fields use. Expand them + * 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. + * + * @param array $result + * + * @return array + */ + public static function applyGeetestSolution(array $result): array + { + $payload = json_decode((string) ($result['code'] ?? ''), true); + if (!is_array($payload)) { + return $result; + } + + $fields = [ + 'challenge' => 'geetest_challenge', + 'validate' => 'geetest_validate', + 'seccode' => 'geetest_seccode', + ]; + foreach ($fields as $short => $prefixed) { + $value = $payload[$prefixed] ?? ($payload[$short] ?? null); + if ($value !== null) { + $result[$short] = $value; + } + } + + return $result; + } + /** * @param array $result * @param array|string $polled @@ -344,6 +408,9 @@ private function prepareSendParams(array $params): array if ($method === 'turnstile') { return ApiParams::prepareSubmitParams($params, 'turnstile'); } + if ($method === 'geetest') { + return ApiParams::prepareSubmitParams($params, 'geetest'); + } return ApiParams::applyProxy(ApiParams::applyParamAliases($params)); } diff --git a/tests/GeetestTest.php b/tests/GeetestTest.php new file mode 100644 index 0000000..4f47880 --- /dev/null +++ b/tests/GeetestTest.php @@ -0,0 +1,216 @@ + */ + public array $incomings = []; + + public string $request; + + public function __construct(?string $request = null) + { + parent::__construct(); + $this->request = $request ?? (string) json_encode(GeetestTest::SOLUTION); + } + + public function in_(array $options = []): string + { + unset($options['files']); + $this->incomings = $options; + + return 'OK|123'; + } + + public function res(array $params = []): string + { + $json = $params['json'] ?? null; + if ($json === 1 || $json === '1') { + return (string) json_encode(['status' => 1, 'request' => $this->request]); + } + + return 'OK|' . $this->request; + } +} + +class GeetestTest extends TestCase +{ + private const GT = '81388ea1fc187e0c335c0a8907ff2625'; + private const CHALLENGE = '7cf6a8b1a2c34d5e6f7089abcdef0123'; + private const URL = 'https://mysite.com/page/with/geetest'; + + public const SOLUTION = [ + 'geetest_challenge' => self::CHALLENGE, + 'geetest_validate' => '9b1f4a2c8e7d6b5a4938271605f4e3d2', + 'geetest_seccode' => '9b1f4a2c8e7d6b5a4938271605f4e3d2|jordan', + ]; + + private CapSkip $solver; + + protected function setUp(): void + { + $this->solver = new CapSkip(['apiKey' => 'API_KEY', 'pollingInterval' => 1]); + $this->solver->apiClient = new MockGeetestApiClient(); + } + + /** @param array $options @return array */ + private function solve(array $options = []): array + { + return $this->solver->geetest(self::GT, self::CHALLENGE, self::URL, $options); + } + + /** @param array $expected */ + private function assertSent(array $expected): void + { + /** @var MockGeetestApiClient $client */ + $client = $this->solver->apiClient; + $this->assertEquals(array_merge($expected, ['key' => 'API_KEY']), $client->incomings); + } + + public function testBasic(): void + { + $result = $this->solve(); + + $this->assertSent([ + 'method' => 'geetest', + 'gt' => self::GT, + 'challenge' => self::CHALLENGE, + 'pageurl' => self::URL, + ]); + $this->assertSame('123', $result['captchaId']); + } + + public function testApiServer(): void + { + $this->solve(['api_server' => 'api-na.geetest.com']); + + $this->assertSent([ + 'method' => 'geetest', + 'gt' => self::GT, + 'challenge' => self::CHALLENGE, + 'pageurl' => self::URL, + 'api_server' => 'api-na.geetest.com', + ]); + } + + public function testApiServerCamelCaseAlias(): void + { + $this->solve(['apiServer' => 'api-na.geetest.com']); + + $this->assertSent([ + 'method' => 'geetest', + 'gt' => self::GT, + 'challenge' => self::CHALLENGE, + 'pageurl' => self::URL, + 'api_server' => 'api-na.geetest.com', + ]); + } + + public function testProxy(): void + { + $this->solve(['proxy' => ['type' => 'HTTP', 'uri' => '1.2.3.4:3128']]); + + $this->assertSent([ + 'method' => 'geetest', + 'gt' => self::GT, + 'challenge' => self::CHALLENGE, + 'pageurl' => self::URL, + 'proxy' => '1.2.3.4:3128', + 'proxytype' => 'HTTP', + ]); + } + + public function testKeepsRawJsonInCode(): void + { + // Kept verbatim so callers can forward it to code written against + // another solver's API. + $result = $this->solve(); + + $this->assertEquals(self::SOLUTION, json_decode($result['code'], true)); + } + + public function testExpandsSolutionFields(): void + { + $result = $this->solve(); + + $this->assertSame(self::SOLUTION['geetest_challenge'], $result['challenge']); + $this->assertSame(self::SOLUTION['geetest_validate'], $result['validate']); + $this->assertSame(self::SOLUTION['geetest_seccode'], $result['seccode']); + } + + public function testNonJsonCodeIsLeftAlone(): void + { + $this->solver->apiClient = new MockGeetestApiClient('not-json'); + + $result = $this->solve(); + + $this->assertSame('not-json', $result['code']); + $this->assertArrayNotHasKey('validate', $result); + } + + public function testMissingChallengeIsRejected(): void + { + $this->expectException(ValidationException::class); + $this->solver->geetest(self::GT, '', self::URL); + } + + public function testMissingGtIsRejected(): void + { + $this->expectException(ValidationException::class); + $this->solver->geetest('', self::CHALLENGE, self::URL); + } + + public function testMissingPageurlIsRejected(): void + { + // pageurl is documented as required; fail locally rather than paying a + // round-trip for ERROR_PAGEURL. + $this->expectException(ValidationException::class); + $this->solver->geetest(self::GT, self::CHALLENGE, ''); + } + + public function testUnsupportedParameterIsRejected(): void + { + $this->expectException(ValidationException::class); + $this->solve(['sitekey' => 'not-a-geetest-param']); + } + + /** @dataProvider acceptedProxyTypes */ + public function testAcceptedProxyTypes(string $proxytype): void + { + $this->solver->apiClient = new MockGeetestApiClient(); + $this->solve(['proxy' => ['type' => $proxytype, 'uri' => '1.2.3.4:3128']]); + + /** @var MockGeetestApiClient $client */ + $client = $this->solver->apiClient; + $this->assertSame($proxytype, $client->incomings['proxytype']); + } + + /** @return array> */ + public function acceptedProxyTypes(): array + { + return [['HTTP'], ['HTTPS'], ['SOCKS5'], ['SOCKS5H'], ['socks5h']]; + } + + public function testSocks4IsRejected(): void + { + // CapSkip maps only HTTP/HTTPS/SOCKS5/SOCKS5H and answers + // ERROR_BAD_PARAMETERS for SOCKS4, so fail before the round-trip. + $this->expectException(ValidationException::class); + $this->solve(['proxy' => ['type' => 'SOCKS4', 'uri' => '1.2.3.4:3128']]); + } + + public function testUnknownProxyTypeIsRejected(): void + { + $this->expectException(ValidationException::class); + $this->solve(['proxy' => '1.2.3.4:3128', 'proxytype' => 'FTP']); + } +}