From 6ac2d8ef34897d9cc66351bae43d314f236a9208 Mon Sep 17 00:00:00 2001 From: "pavel.grinkevich" Date: Tue, 27 Oct 2026 12:15:22 +0000 Subject: [PATCH 1/3] Rewrite SDK 2.0: Composer/PSR-4, PHP 8.1+, all current task types - Installable via Composer with PSR-4 autoload (fixes #12) - CapMonsterClient with typed exceptions, pluggable HTTP transport - Request classes for all task types from the current API docs - PHPUnit tests, CI matrix PHP 8.1-8.5, release workflow - README code snippets updated to the 2.0 API Co-Authored-By: Claude Opus 5.5 --- .github/workflows/autoTag.yml | 16 - .github/workflows/ci.yml | 39 +++ .github/workflows/php.yml | 24 -- .github/workflows/release.yml | 62 ++++ .gitignore | 5 +- CHANGELOG.md | 34 +++ Client.php | 104 ------- README.md | 121 +++++--- client/Client.php | 104 ------- client/src/CaptchaRequest.php | 153 ---------- client/src/Networker.php | 45 --- client/src/Result.php | 21 -- client/src/Timeouts.php | 88 ------ client/src/Validator.php | 52 ---- client/src/captcha/ComplexImage.php | 26 -- client/src/captcha/ComplexImageFuncaptcha.php | 18 -- client/src/captcha/ComplexImageHCaptcha.php | 18 -- client/src/captcha/ComplexImageRecaptcha.php | 19 -- client/src/captcha/FunCaptcha.php | 36 --- client/src/captcha/GeeTest.php | 40 --- client/src/captcha/HCaptcha.php | 37 --- client/src/captcha/ImageToText.php | 20 -- client/src/captcha/RecaptchaV2.php | 35 --- client/src/captcha/RecaptchaV2Enterprise.php | 37 --- client/src/captcha/RecaptchaV3.php | 18 -- client/src/captcha/Turnstile.php | 41 --- client/tests/phpunit.xml | 7 - client/tests/test.php | 246 --------------- composer.json | 33 +- examples/image_to_text.php | 14 + examples/low_level.php | 26 ++ examples/recaptcha_v2.php | 24 ++ examples/turnstile_with_proxy.php | 23 ++ phpunit.xml.dist | 19 ++ src/CapMonsterClient.php | 184 ++++++++++++ src/ClientOptions.php | 40 +++ src/ClientProxy.php | 38 +++ src/Exception/ApiException.php | 20 ++ src/Exception/CapMonsterException.php | 10 + src/Exception/CreateTaskException.php | 10 + src/Exception/GetBalanceException.php | 10 + src/Exception/GetResultException.php | 10 + src/Exception/MalformedResponseException.php | 10 + src/Exception/TimeoutException.php | 16 + src/Exception/TransportException.php | 10 + .../UnexpectedHttpStatusException.php | 16 + src/Exception/ValidationException.php | 10 + src/GetResultTimeouts.php | 21 ++ src/Http/CurlTransport.php | 68 +++++ src/Http/HttpResponse.php | 14 + src/Http/HttpTransport.php | 21 ++ src/ProxyInfo.php | 57 ++++ src/ProxyType.php | 14 + src/Requests/AbstractRequest.php | 71 +++++ src/Requests/AlibabaCustomTaskRequest.php | 82 +++++ src/Requests/AltchaCustomTaskRequest.php | 60 ++++ src/Requests/AmazonWafRequest.php | 132 ++++++++ src/Requests/BasiliskCustomTaskRequest.php | 47 +++ src/Requests/BinanceTaskRequest.php | 53 ++++ src/Requests/CaptchaRequest.php | 29 ++ src/Requests/ComplexImage.php | 52 ++++ src/Requests/DataDomeCustomTaskRequest.php | 65 ++++ src/Requests/FriendlyCustomTaskRequest.php | 53 ++++ src/Requests/FuncaptchaRequest.php | 59 ++++ src/Requests/GeetestRequest.php | 102 +++++++ src/Requests/HuntCustomTaskRequest.php | 83 +++++ src/Requests/ImageToTextRequest.php | 82 +++++ src/Requests/ImpervaCustomTaskRequest.php | 57 ++++ src/Requests/MtCaptchaRequest.php | 51 ++++ src/Requests/ProsopoTaskRequest.php | 45 +++ .../RecaptchaComplexImageTaskRequest.php | 69 +++++ src/Requests/RecaptchaGrid.php | 13 + src/Requests/RecaptchaV2EnterpriseRequest.php | 66 ++++ src/Requests/RecaptchaV2Request.php | 55 ++++ src/Requests/RecaptchaV3EnterpriseRequest.php | 48 +++ src/Requests/RecaptchaV3ProxylessRequest.php | 60 ++++ .../RecognitionComplexImageTaskRequest.php | 64 ++++ src/Requests/TenDiCustomTaskRequest.php | 55 ++++ src/Requests/TextModule.php | 48 +++ src/Requests/TspdCustomTaskRequest.php | 56 ++++ src/Requests/TurnstileRequest.php | 145 +++++++++ src/Requests/YidunRequest.php | 62 ++++ src/TaskResult.php | 29 ++ tests/ClientTest.php | 194 ++++++++++++ tests/FakeTransport.php | 57 ++++ tests/RequestsTest.php | 284 ++++++++++++++++++ 86 files changed, 3360 insertions(+), 1252 deletions(-) delete mode 100644 .github/workflows/autoTag.yml create mode 100644 .github/workflows/ci.yml delete mode 100644 .github/workflows/php.yml create mode 100644 .github/workflows/release.yml create mode 100644 CHANGELOG.md delete mode 100644 Client.php delete mode 100644 client/Client.php delete mode 100644 client/src/CaptchaRequest.php delete mode 100644 client/src/Networker.php delete mode 100644 client/src/Result.php delete mode 100644 client/src/Timeouts.php delete mode 100644 client/src/Validator.php delete mode 100644 client/src/captcha/ComplexImage.php delete mode 100644 client/src/captcha/ComplexImageFuncaptcha.php delete mode 100644 client/src/captcha/ComplexImageHCaptcha.php delete mode 100644 client/src/captcha/ComplexImageRecaptcha.php delete mode 100644 client/src/captcha/FunCaptcha.php delete mode 100644 client/src/captcha/GeeTest.php delete mode 100644 client/src/captcha/HCaptcha.php delete mode 100644 client/src/captcha/ImageToText.php delete mode 100644 client/src/captcha/RecaptchaV2.php delete mode 100644 client/src/captcha/RecaptchaV2Enterprise.php delete mode 100644 client/src/captcha/RecaptchaV3.php delete mode 100644 client/src/captcha/Turnstile.php delete mode 100644 client/tests/phpunit.xml delete mode 100644 client/tests/test.php create mode 100644 examples/image_to_text.php create mode 100644 examples/low_level.php create mode 100644 examples/recaptcha_v2.php create mode 100644 examples/turnstile_with_proxy.php create mode 100644 phpunit.xml.dist create mode 100644 src/CapMonsterClient.php create mode 100644 src/ClientOptions.php create mode 100644 src/ClientProxy.php create mode 100644 src/Exception/ApiException.php create mode 100644 src/Exception/CapMonsterException.php create mode 100644 src/Exception/CreateTaskException.php create mode 100644 src/Exception/GetBalanceException.php create mode 100644 src/Exception/GetResultException.php create mode 100644 src/Exception/MalformedResponseException.php create mode 100644 src/Exception/TimeoutException.php create mode 100644 src/Exception/TransportException.php create mode 100644 src/Exception/UnexpectedHttpStatusException.php create mode 100644 src/Exception/ValidationException.php create mode 100644 src/GetResultTimeouts.php create mode 100644 src/Http/CurlTransport.php create mode 100644 src/Http/HttpResponse.php create mode 100644 src/Http/HttpTransport.php create mode 100644 src/ProxyInfo.php create mode 100644 src/ProxyType.php create mode 100644 src/Requests/AbstractRequest.php create mode 100644 src/Requests/AlibabaCustomTaskRequest.php create mode 100644 src/Requests/AltchaCustomTaskRequest.php create mode 100644 src/Requests/AmazonWafRequest.php create mode 100644 src/Requests/BasiliskCustomTaskRequest.php create mode 100644 src/Requests/BinanceTaskRequest.php create mode 100644 src/Requests/CaptchaRequest.php create mode 100644 src/Requests/ComplexImage.php create mode 100644 src/Requests/DataDomeCustomTaskRequest.php create mode 100644 src/Requests/FriendlyCustomTaskRequest.php create mode 100644 src/Requests/FuncaptchaRequest.php create mode 100644 src/Requests/GeetestRequest.php create mode 100644 src/Requests/HuntCustomTaskRequest.php create mode 100644 src/Requests/ImageToTextRequest.php create mode 100644 src/Requests/ImpervaCustomTaskRequest.php create mode 100644 src/Requests/MtCaptchaRequest.php create mode 100644 src/Requests/ProsopoTaskRequest.php create mode 100644 src/Requests/RecaptchaComplexImageTaskRequest.php create mode 100644 src/Requests/RecaptchaGrid.php create mode 100644 src/Requests/RecaptchaV2EnterpriseRequest.php create mode 100644 src/Requests/RecaptchaV2Request.php create mode 100644 src/Requests/RecaptchaV3EnterpriseRequest.php create mode 100644 src/Requests/RecaptchaV3ProxylessRequest.php create mode 100644 src/Requests/RecognitionComplexImageTaskRequest.php create mode 100644 src/Requests/TenDiCustomTaskRequest.php create mode 100644 src/Requests/TextModule.php create mode 100644 src/Requests/TspdCustomTaskRequest.php create mode 100644 src/Requests/TurnstileRequest.php create mode 100644 src/Requests/YidunRequest.php create mode 100644 src/TaskResult.php create mode 100644 tests/ClientTest.php create mode 100644 tests/FakeTransport.php create mode 100644 tests/RequestsTest.php diff --git a/.github/workflows/autoTag.yml b/.github/workflows/autoTag.yml deleted file mode 100644 index 8afdc8e..0000000 --- a/.github/workflows/autoTag.yml +++ /dev/null @@ -1,16 +0,0 @@ -name: auto_tagger -on: - push: - branches: - - main -jobs: - build: - runs-on: ubuntu-20.04 - steps: - - uses: actions/checkout@master - with: - fetch-depth: 0 - - name: Bump version and push tag - uses: itigertechno/auto-tagger@master - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..0375b83 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,39 @@ +name: CI + +on: + pull_request: + push: + branches: [main] + +jobs: + test: + name: PHP ${{ matrix.php }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php: ['8.1', '8.2', '8.3', '8.4', '8.5'] + steps: + - uses: actions/checkout@v4 + - uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: curl, json + coverage: none + - run: composer validate --strict + - uses: ramsey/composer-install@v3 + - name: Lint + run: find src tests examples -name '*.php' -print0 | xargs -0 -n1 php -l > /dev/null + # Unit tests + client tests against a fake transport: no network or API key needed. + - run: vendor/bin/phpunit + + release-readiness: + name: version + changelog + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: CHANGELOG.md has an entry for CapMonsterClient::VERSION + run: | + VERSION=$(sed -nE "s/.*const VERSION = '([^']+)'.*/\1/p" src/CapMonsterClient.php) + echo "version: $VERSION" + grep -q "^## \[$VERSION\]" CHANGELOG.md || { echo "CHANGELOG.md has no '## [$VERSION]' section"; exit 1; } diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml deleted file mode 100644 index 807192c..0000000 --- a/.github/workflows/php.yml +++ /dev/null @@ -1,24 +0,0 @@ -name: Tests - -on: - push: - branches: [ "main" ] - pull_request: - branches: [ "main" ] - workflow_dispatch: - -jobs: - build-test: - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v3 - - uses: shivammathur/setup-php@v2 - with: - php-version: '7.1' - - uses: "ramsey/composer-install@v2" - - uses: php-actions/phpunit@v3 - env: - CLIENT_KEY: ${{secrets.CLIENT_KEY}} - with: - configuration: ./client/tests/phpunit.xml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..4fec4ca --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,62 @@ +name: Release + +# Tags `vX.Y.Z` and creates a GitHub Release when CapMonsterClient::VERSION on main has no tag yet; +# Packagist picks the new tag up through its GitHub hook. Any other push to main is a no-op. +# To release: bump CapMonsterClient::VERSION + add a `## [X.Y.Z]` section to CHANGELOG.md in a PR and merge it. +on: + push: + branches: [main] + workflow_dispatch: + +concurrency: + group: release + cancel-in-progress: false + +jobs: + release: + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Read version + id: v + run: echo "version=$(sed -nE "s/.*const VERSION = '([^']+)'.*/\1/p" src/CapMonsterClient.php)" >> "$GITHUB_OUTPUT" + + - name: Already tagged? + id: tagged + run: | + if git rev-parse -q --verify "refs/tags/v${{ steps.v.outputs.version }}" > /dev/null; then + echo "exists=true" >> "$GITHUB_OUTPUT" + echo "v${{ steps.v.outputs.version }} already exists, nothing to release." + else + echo "exists=false" >> "$GITHUB_OUTPUT" + fi + + - uses: shivammathur/setup-php@v2 + if: steps.tagged.outputs.exists == 'false' + with: + php-version: '8.3' + coverage: none + - uses: ramsey/composer-install@v3 + if: steps.tagged.outputs.exists == 'false' + + - name: Checks before releasing + if: steps.tagged.outputs.exists == 'false' + run: | + grep -q "^## \[${{ steps.v.outputs.version }}\]" CHANGELOG.md \ + || { echo "CHANGELOG.md has no section for ${{ steps.v.outputs.version }}"; exit 1; } + vendor/bin/phpunit + + - name: Tag and GitHub Release + if: steps.tagged.outputs.exists == 'false' + run: | + gh release create "v${{ steps.v.outputs.version }}" \ + --target "${{ github.sha }}" \ + --title "v${{ steps.v.outputs.version }}" \ + --generate-notes + env: + GH_TOKEN: ${{ github.token }} diff --git a/.gitignore b/.gitignore index b42bb23..a3b7644 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,5 @@ -/client/vendor +/vendor/ +composer.lock +.phpunit.cache/ +.phpunit.result.cache .idea/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..4a262d1 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,34 @@ +# Changelog + +All notable changes to this package are documented here. The format follows +[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), versions follow [SemVer](https://semver.org/). + +## [2.0.0] + +Full rewrite. **Breaking:** the 1.x API (`Client`, `*Request` classes in the global namespace, +`Result{result, message}`) is removed; see "Migrating from 1.x" in README.md. + +### Added +- Composer package with PSR-4 autoloading (`Zennolab\CapMonsterCloud\`): works from `vendor/` in any project + (fixes #12 "Not compatible using composer"). Requires PHP 8.1+, `ext-curl`, `ext-json`; no runtime dependencies. +- `CapMonsterClient`: `solve()` (createTask + polling with a per-type schedule), low-level `createTask()` / + `getTaskResult()`, `getBalance()`. Errors are exceptions under `CapMonsterException` + (`ValidationException`, `CreateTaskException` / `GetResultException` / `GetBalanceException` with `errorCode`, + `TimeoutException`, `TransportException`, `UnexpectedHttpStatusException`, `MalformedResponseException`). +- `ClientOptions`: service URL, `softId`, per-request HTTP timeout, a proxy for the SDK's own requests + (http/https/socks4/socks5), polling-schedule override. Pluggable `HttpTransport` (default: curl, keep-alive). +- Request types matching the current API documentation: reCAPTCHA v2 / v2 Enterprise / v3 / v3 Enterprise, + reCAPTCHA click and Recognition ComplexImageTask, ImageToText, FunCaptcha, GeeTest v3/v4, + Turnstile (standard, Cloudflare Challenge token / cf_clearance, Waiting Room), Amazon WAF (3 variants), + Binance, MTCaptcha, Yidun, Prosopo, and CustomTask: DataDome, Imperva, TSPD, HUNT, Basilisk, TenDI, Alibaba, + Friendly Captcha, Altcha. +- Local validation of requests before anything is sent; the API key and proxy passwords are masked in + `var_dump()` / `print_r()`. + +### Removed +- `HCaptchaRequest`, `ComplexImageHCaptchaRequest`, `ComplexImageFuncaptchaRequest`, `ComplexImageRequest`: + no longer in the API documentation. +- `*Proxyless` task types: the API now uses one task type per captcha with an optional proxy. + +### Fixed +- `ImageToTextTask` sends `capMonsterModule` / `case` (documented names) instead of `CapMonsterModule` / `Case`. diff --git a/Client.php b/Client.php deleted file mode 100644 index 1ae6700..0000000 --- a/Client.php +++ /dev/null @@ -1,104 +0,0 @@ -startTime = date_create()->format('Uv'); - $this->endTime = $this->startTime + $this->totalTimeToTask; - } - - private function isTimeExpired() { - $currentTime = date_create()->format('Uv'); - return $currentTime >= $this->endTime; - } - - private function tryParseGetBalanceError(string $rawError) : string { - $parsed = json_decode($rawError, true); - - if(json_last_error() === JSON_ERROR_NONE) { - return $parsed['errorCode']; - } else { - return 'Curl: ' . $rawError; - } - } - - public function __construct(string $clientKey) { - $this->clientKey = $clientKey; - } - - public function solve(CaptchaRequest $request) : Result { - if(!$request->isValid()) { - return $request->getTrouble(); - } - - $this->totalTimeToTask = $request->getTotalTime(); - - $request->setClientKey($this->clientKey); - - try { - $request->createTask(); - } catch (Exception $e) { - return new Result(false, $e->getMessage()); - } - - $this->startTask(); - usleep($request->getFirstDelay()); - - while(!$this->isTimeExpired()) { - try { - $request->getTaskResult(); //помним, что 503 ошибка игнорируется - } catch (Exception $e) { - return new Result(false, $e->getMessage()); - } - - if($request->isReady) { - return new Result(true, $request->solution); - } - - //если интервал до следующего запроса больше времени, оставшегося на выполнение - if($request->getInterval() > $this->endTime - date_create()->format('Uv')) { - return new Result(false, 'Timeout'); - } - - usleep($request->getInterval()); - } - - return new Result(false, 'Timeout'); - } - - public function getBalance() : Result { - $url = $this->apiEndpoint . 'getBalance'; - - $response = null; - try { - $response = $this->postRequest($url, ["clientKey" => $this->clientKey], $this->userAgent); - } catch (Exception $e) { - $message = $this->tryParseGetBalanceError($e->getMessage()); - return new Result(false, $message); - } - - $jsonResponse = json_decode($response, true); - - if($jsonResponse['errorId'] == 1) { - $message = $jsonResponse['errorDescription']; - return new Result(false, $message); - } - - return new Result(true, $jsonResponse['balance']); - } - -} diff --git a/README.md b/README.md index f6732ba..8260d5d 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ PHP client library for the [CapMonster Cloud](https://capmonster.cloud/en/?utm_s - 🤖 **Zero human workers:** 100% automated machine learning pipeline with no manual latencies. - 🛡️ **Modern anti-bot bypass:** Support for Cloudflare Turnstile, reCAPTCHA Enterprise, GeeTest and FunCaptcha. - 🧩 **One call to solve a captcha:** create a request object and pass it to `$client->solve()`. -- 🔁 **Unified response:** every call returns the same structure, so success and error handling stays simple. +- 🔁 **Simple results and typed errors:** `solve()` returns the solution as an array; failures throw exceptions from one `CapMonsterException` hierarchy. - 💰 **Cost-effective:** Low-cost pricing structure based on pay-as-you-go balance. - 📉 **Cheaper than alternatives:** reCAPTCHA v3 from $0.90 per 1,000 solves, versus $1.45 at 2Captcha and $1.00 at Capsolver. - 🔄 **Easy migration:** replace your 2Captcha or Capsolver solver call and keep the rest of your scraper or bot unchanged. @@ -73,41 +73,71 @@ Looking for a 2captcha alternative or a capsolver alternative for PHP web scrapi ## 📦 Installation -Clone or download this repository and include the client files as shown in the Usage example below. The examples use relative `include` paths, so run them from the repository root. +Install the package with [Composer](https://getcomposer.org/). PHP 8.1 or newer with the `curl` and `json` extensions is required. + +```bash +composer require zennolab/capmonstercloud.client +``` + +Then load Composer's autoloader with `require 'vendor/autoload.php';` as shown below. --- ## 🚀 Quick Start: Solve and Bypass Captchas in PHP ```php - include './client/Client.php'; - include './client/src/captcha/ImageToText.php'; - include './client/src/captcha/RecaptchaV2.php'; - include './client/src/captcha/HCaptcha.php'; - - $client = new Client("your_client_key"); - - //solve image captcha - $body = "base64_captcha_image"; - $imageRequest = new ImageToTextRequest($body); - $imageResult = $client->solve($imageRequest); - - //solve Recaptcha 2 (without proxy) - $websiteURL = "https://lessons.zennolab.com/captchas/recaptcha/v2_simple.php?level=high"; - $websiteKey = "6Lcg7CMUAAAAANphynKgn9YAgA4tQ2KI_iqRyTwd"; - $recaptchaV2Request = new RecaptchaV2Request($websiteURL, $websiteKey); - $recaptchaV2Result = $client->solve($recaptchaV2Request); - - // solve HCaptcha (without proxy) - $websiteUrl = "https://lessons.zennolab.com/captchas/hcaptcha/?level=easy"; - $websiteKey = "472fc7af-86a4-4382-9a49-ca9090474471"; - $hcatpchaRequest = new HCaptchaRequest($websiteURL, $websiteKey); - $hcaptchaResult = $client->solve($hcatpchaRequest); +getBalance(), PHP_EOL; + +// solve image captcha +$imageResult = $client->solve(new ImageToTextRequest('base64_captcha_image')); +echo $imageResult['text'], PHP_EOL; + +// solve reCAPTCHA v2 (without proxy) +$recaptchaV2Result = $client->solve(new RecaptchaV2Request( + 'https://lessons.zennolab.com/captchas/recaptcha/v2_simple.php?level=high', + '6Lcg7CMUAAAAANphynKgn9YAgA4tQ2KI_iqRyTwd', +)); +echo $recaptchaV2Result['gRecaptchaResponse'], PHP_EOL; + +// solve Cloudflare Turnstile (without proxy) +$turnstileResult = $client->solve(TurnstileRequest::standard( + 'https://target-website.com/login', + '0x4AAAAAAABnPIDnK2k_e-2', +)); +echo $turnstileResult['token'], PHP_EOL; ``` ### Response format -The result of the solve method always contains two fields: bool result, a request success indicator, and a mixed message field containing a text description of the error or an object of a successful response from the server. +`solve()` returns the `solution` object of the server response as an associative array, for example `['gRecaptchaResponse' => '...']` for reCAPTCHA or `['text' => '...']` for an image. If the task cannot be solved, `solve()` throws an exception: + +```php +use Zennolab\CapMonsterCloud\Exception\CapMonsterException; +use Zennolab\CapMonsterCloud\Exception\GetResultException; +use Zennolab\CapMonsterCloud\Exception\TimeoutException; + +try { + $solution = $client->solve($request); +} catch (GetResultException $e) { + // the API returned an error, e.g. $e->errorCode === 'ERROR_CAPTCHA_UNSOLVABLE' +} catch (TimeoutException $e) { + // no solution within the maximum waiting time of this captcha type +} catch (CapMonsterException $e) { + // anything else: invalid request, network error, wrong key, zero balance... +} +``` --- @@ -135,22 +165,25 @@ CapMonster Cloud uses the same `createTask` / `getTaskResult` flow, so if you ca ```php solve($request); -if ($result->result) { - $token = $result->message['gRecaptchaResponse']; +try { + $token = $client->solve($request)['gRecaptchaResponse']; echo $token; -} else { - echo $result->message; +} catch (CapMonsterException $e) { + echo $e->getMessage(); } ``` @@ -170,12 +203,12 @@ All task types follow the official [CapMonster Cloud API documentation](https:// | Captcha type | PHP request classes | Documentation | | :--- | :--- | :--- | -| reCAPTCHA v2 | `RecaptchaV2ProxylessRequest`, `RecaptchaV2Request` | [RecaptchaV2Task](https://docs.capmonster.cloud/docs/captchas/no-captcha-task/?utm_source=github&utm_medium=referral&utm_campaign=php_repo_readme) | -| reCAPTCHA v2 Enterprise | `RecaptchaV2EnterpriseProxylessRequest`, `RecaptchaV2EnterpriseRequest` | [RecaptchaV2EnterpriseTask](https://docs.capmonster.cloud/docs/captchas/recaptcha-v2-enterprise-task/?utm_source=github&utm_medium=referral&utm_campaign=php_repo_readme) | +| reCAPTCHA v2 | `RecaptchaV2Request` (optional proxy) | [RecaptchaV2Task](https://docs.capmonster.cloud/docs/captchas/no-captcha-task/?utm_source=github&utm_medium=referral&utm_campaign=php_repo_readme) | +| reCAPTCHA v2 Enterprise | `RecaptchaV2EnterpriseRequest` (optional proxy) | [RecaptchaV2EnterpriseTask](https://docs.capmonster.cloud/docs/captchas/recaptcha-v2-enterprise-task/?utm_source=github&utm_medium=referral&utm_campaign=php_repo_readme) | | reCAPTCHA v3 | `RecaptchaV3ProxylessRequest` | [RecaptchaV3TaskProxyless](https://docs.capmonster.cloud/docs/captchas/recaptcha-v3-task/?utm_source=github&utm_medium=referral&utm_campaign=php_repo_readme) | | reCAPTCHA image-click (ComplexImageTask) | `RecaptchaComplexImageTaskRequest` | [ComplexImageTask reCAPTCHA](https://docs.capmonster.cloud/docs/captchas/recaptcha-click/?utm_source=github&utm_medium=referral&utm_campaign=php_repo_readme) | -| Cloudflare Turnstile | `TurnstileProxylessRequest`, `TurnstileRequest` | [TurnstileTask](https://docs.capmonster.cloud/docs/captchas/turnstile-task/?utm_source=github&utm_medium=referral&utm_campaign=php_repo_readme) | -| GeeTest | `GeeTestProxylessRequest`, `GeeTestRequest` | [GeeTestTask](https://docs.capmonster.cloud/docs/captchas/geetest-task/?utm_source=github&utm_medium=referral&utm_campaign=php_repo_readme) | +| Cloudflare Turnstile | `TurnstileRequest::standard()` and other `TurnstileRequest` factories | [TurnstileTask](https://docs.capmonster.cloud/docs/captchas/turnstile-task/?utm_source=github&utm_medium=referral&utm_campaign=php_repo_readme) | +| GeeTest | `GeetestRequest::v3()`, `GeetestRequest::v4()` | [GeeTestTask](https://docs.capmonster.cloud/docs/captchas/geetest-task/?utm_source=github&utm_medium=referral&utm_campaign=php_repo_readme) | | Text captcha (image to text) | `ImageToTextRequest` | [ImageToTextTask](https://docs.capmonster.cloud/docs/captchas/image-to-text/?utm_source=github&utm_medium=referral&utm_campaign=php_repo_readme) | --- @@ -227,16 +260,16 @@ Yes. It is a captcha solving service with an official PHP SDK. For reCAPTCHA v3 Yes. For reCAPTCHA v3 it is 10% cheaper than Capsolver ($0.90 versus $1.00 per 1,000 solves) and shows an API response time of ≤ 2 s versus < 3 s. See the comparison table above and its disclaimer. **How do I migrate from 2Captcha or Capsolver to CapMonster Cloud in PHP?** -Create a `Client` with your CapMonster Cloud API key, build the matching request class and call `$client->solve()`. See the migration examples above. Software that already supports the 2Captcha API can also be connected through the [CapMonster Cloud guide](https://docs.capmonster.cloud/docs/external-services/?utm_source=github&utm_medium=referral&utm_campaign=php_repo_readme). +Create a `CapMonsterClient` with your CapMonster Cloud API key, build the matching request class and call `$client->solve()`. See the migration examples above. Software that already supports the 2Captcha API can also be connected through the [CapMonster Cloud guide](https://docs.capmonster.cloud/docs/external-services/?utm_source=github&utm_medium=referral&utm_campaign=php_repo_readme). **Is there an official PHP captcha solver from CapMonster Cloud?** Yes. This repository is the official PHP client library for the CapMonster Cloud captcha solving service. **How do I bypass reCAPTCHA v2 in PHP?** -Create a `Client` with your API key, build a `RecaptchaV2Request` with the page URL and sitekey, call `$client->solve($request)` and read the token from `$result->message['gRecaptchaResponse']`. See the [reCAPTCHA v2 documentation](https://docs.capmonster.cloud/docs/captchas/no-captcha-task/?utm_source=github&utm_medium=referral&utm_campaign=php_repo_readme). +Create a `CapMonsterClient` with your API key, build a `RecaptchaV2Request` with the page URL and sitekey, call `$client->solve($request)` and read the token from the `gRecaptchaResponse` key of the returned array. See the [reCAPTCHA v2 documentation](https://docs.capmonster.cloud/docs/captchas/no-captcha-task/?utm_source=github&utm_medium=referral&utm_campaign=php_repo_readme). **How do I bypass Cloudflare Turnstile in PHP?** -Use `TurnstileProxylessRequest` or `TurnstileRequest` from the supported types table above. According to the [Turnstile documentation](https://docs.capmonster.cloud/docs/captchas/turnstile-task/?utm_source=github&utm_medium=referral&utm_campaign=php_repo_readme), all Turnstile subtypes are supported automatically: manual, non-interactive and invisible. +Use `TurnstileRequest::standard()` (pass `proxy:` to solve through your proxy) from the supported types table above. According to the [Turnstile documentation](https://docs.capmonster.cloud/docs/captchas/turnstile-task/?utm_source=github&utm_medium=referral&utm_campaign=php_repo_readme), all Turnstile subtypes are supported automatically: manual, non-interactive and invisible. **Which captcha types does the PHP SDK solve?** reCAPTCHA v2, v2 Enterprise and v3, Cloudflare Turnstile, GeeTest, image-to-text and reCAPTCHA image-click tasks. See the supported types table above and the [Supported Captchas Overview](https://docs.capmonster.cloud/docs/captchas/?utm_source=github&utm_medium=referral&utm_campaign=php_repo_readme). @@ -254,7 +287,7 @@ According to the [reCAPTCHA v2 documentation](https://docs.capmonster.cloud/docs See the [API Methods Reference](https://docs.capmonster.cloud/docs/methods/?utm_source=github&utm_medium=referral&utm_campaign=php_repo_readme). **What does the `solve` method return?** -A result with two fields: a boolean `result` (request success indicator) and a `message` that holds either a text description of the error or the object of a successful server response. +The `solution` object of the server response as an associative array. Errors are thrown as exceptions that extend `CapMonsterException`, for example `GetResultException` with the API `errorCode`, or `TimeoutException`. --- diff --git a/client/Client.php b/client/Client.php deleted file mode 100644 index 395f38c..0000000 --- a/client/Client.php +++ /dev/null @@ -1,104 +0,0 @@ -startTime = date_create()->format('Uv'); - $this->endTime = $this->startTime + $this->totalTimeToTask; - } - - private function isTimeExpired() { - $currentTime = date_create()->format('Uv'); - return $currentTime >= $this->endTime; - } - - private function tryParseGetBalanceError(string $rawError) : string { - $parsed = json_decode($rawError, true); - - if(json_last_error() === JSON_ERROR_NONE) { - return $parsed['errorCode']; - } else { - return 'Curl: ' . $rawError; - } - } - - public function __construct(string $clientKey) { - $this->clientKey = $clientKey; - } - - public function solve(CaptchaRequest $request) : Result { - if(!$request->isValid()) { - return $request->getTrouble(); - } - - $this->totalTimeToTask = $request->getTotalTime(); - - $request->setClientKey($this->clientKey); - - try { - $request->createTask(); - } catch (Exception $e) { - return new Result(false, $e->getMessage()); - } - - $this->startTask(); - usleep($request->getFirstDelay()); - - while(!$this->isTimeExpired()) { - try { - $request->getTaskResult(); //помним, что 503 ошибка игнорируется - } catch (Exception $e) { - return new Result(false, $e->getMessage()); - } - - if($request->isReady) { - return new Result(true, $request->solution); - } - - //если интервал до следующего запроса больше времени, оставшегося на выполнение - if($request->getInterval() > $this->endTime - date_create()->format('Uv')) { - return new Result(false, 'Timeout'); - } - - usleep($request->getInterval()); - } - - return new Result(false, 'Timeout'); - } - - public function getBalance() : Result { - $url = $this->apiEndpoint . 'getBalance'; - - $response = null; - try { - $response = $this->postRequest($url, ["clientKey" => $this->clientKey], $this->userAgent); - } catch (Exception $e) { - $message = $this->tryParseGetBalanceError($e->getMessage()); - return new Result(false, $message); - } - - $jsonResponse = json_decode($response, true); - - if($jsonResponse['errorId'] == 1) { - $message = $jsonResponse['errorDescription']; - return new Result(false, $message); - } - - return new Result(true, $jsonResponse['balance']); - } - -} \ No newline at end of file diff --git a/client/src/CaptchaRequest.php b/client/src/CaptchaRequest.php deleted file mode 100644 index 72b62a0..0000000 --- a/client/src/CaptchaRequest.php +++ /dev/null @@ -1,153 +0,0 @@ -type = $type; - - $this->validate = Validator::validate($type, $options); - - $options = $this->clearInput($options); - - $options["type"] = $type; - $this->options = [ - "task" => $options, - "softId" => 57 - ]; - - $this->timeouts = Timeouts::detectTimeouts($type); - } - - public function getFirstDelay() : int { - return $this->timeouts['firstRequestDelay']; - } - - public function getInterval() : int { - return $this->timeouts['requestInterval']; - } - - public function getTotalTime() : int { - return $this->timeouts['timeout']; - } - - public function isValid() : bool { - return $this->validate->result; - } - - public function getTrouble() : Result { - return $this->validate; - } - - public function setClientKey(string $clientKey) : void { - $this->options["clientKey"] = $clientKey; - } - - public function createTask() : void { - $url = $this->apiEndpoint . 'createTask'; - - $response = null; - - try { - //postRequest выбрасывает исключение при ошибках curl (timeout reached например), а также при ошибках api благодаря http кодам - $response = $this->postRequest($url, $this->options, $this->userAgent); - } catch (Exception $e) { - $message = $this->tryParseCreateTaskError($e->getMessage()); - throw new Exception($message); - } - - $jsonResponse = json_decode($response, true); - - if($jsonResponse['errorId'] == 1) { - $message = $jsonResponse['errorDescription']; - throw new Exception($message); - } - - $this->taskId = $jsonResponse['taskId']; - } - - public function getTaskResult() : void { - $url = $this->apiEndpoint . 'getTaskResult'; - - $response = null; - $payload = [ - "clientKey" => $this->options['clientKey'], - "taskId" => $this->taskId - ]; - - try { - //postRequest выбрасывает исключение при ошибках curl (timeout reached например), а также при ошибках api благодаря http кодам - $response = $this->postRequestIgnore503($url, $payload, $this->userAgent); - } catch (Exception $e) { - $message = $this->tryParseGetResultError($e->getMessage()); - throw new Exception($message); - } - - $jsonResponse = json_decode($response, true); - - if(json_last_error() != JSON_ERROR_NONE) { //произошла 503 ошибка, и в $response лежит обычная строчка => json_decode выполнится с ошибкой - return; - } - - if($jsonResponse['errorId'] == 1) { - $message = $jsonResponse['errorCode']; - throw new Exception($message); - } - - if($jsonResponse['status'] == 'ready') { - $this->isReady = true; - $this->solution = $jsonResponse['solution']; - } - } - -} \ No newline at end of file diff --git a/client/src/Networker.php b/client/src/Networker.php deleted file mode 100644 index d660b01..0000000 --- a/client/src/Networker.php +++ /dev/null @@ -1,45 +0,0 @@ - $jsonPayload, - CURLOPT_HTTPHEADER => array("Content-Type:application/json"), - CURLOPT_RETURNTRANSFER => true, - CURLOPT_TIMEOUT => 21, - CURLOPT_USERAGENT => $userAgent - )); - - $response = curl_exec($ch); - $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); - - if ($curlErrorNumber = curl_errno($ch)) { - curl_close($ch); - throw new Exception(curl_strerror($curlErrorNumber)); - } - curl_close($ch); - - if($this->isErrorCode($httpCode) && !in_array($httpCode, $ignoredHttpCodes)) { - throw new Exception($response); - } - - return $response; - } - - protected function postRequestIgnore503(string $url, array $payload, string $userAgent) : string { - return $this->postRequest($url, $payload, $userAgent, [503]); - } - - private function isErrorCode(int $code) : bool { - $firstDigit = substr((string)$code, 0, 1); - return $firstDigit == '4' || $firstDigit == '5'; - } - -} \ No newline at end of file diff --git a/client/src/Result.php b/client/src/Result.php deleted file mode 100644 index 4e625a2..0000000 --- a/client/src/Result.php +++ /dev/null @@ -1,21 +0,0 @@ -result = $result; - $this->message = $message; - } - - public function setResult(bool $newResult) : void { - $this->result = $newResult; - } - - public function setMessage($newMessage) : void { - $this->message = $newMessage; - } - -} \ No newline at end of file diff --git a/client/src/Timeouts.php b/client/src/Timeouts.php deleted file mode 100644 index 192d84b..0000000 --- a/client/src/Timeouts.php +++ /dev/null @@ -1,88 +0,0 @@ - 1000 * 1, - "requestInterval" => 1000 * 3, - "timeout" => 1000 * 180 - ]; - - public static $recaptchaV2EnterpriseTimeouts = [ - "firstRequestDelay" => 1000 * 1, - "requestInterval" => 1000 * 3, - "timeout" => 1000 * 180 - ]; - - public static $recaptchaV3Timeouts = [ - "firstRequestDelay" => 1000 * 1, - "requestInterval" => 1000 * 3, - "timeout" => 1000 * 180 - ]; - - public static $imageToTextTimeouts = [ - "firstRequestDelay" => 350, - "requestInterval" => 200, - "timeout" => 1000 * 10 - ]; - - public static $funCaptchaTimeouts = [ - "firstRequestDelay" => 1000 * 1, - "requestInterval" => 1000 * 1, - "timeout" => 1000 * 80 - ]; - - public static $hCaptchaTimeouts = [ - "firstRequestDelay" => 1000 * 1, - "requestInterval" => 1000 * 3, - "timeout" => 1000 * 180 - ]; - - public static $geeTestTimeouts = [ - "firstRequestDelay" => 1000 * 1, - "requestInterval" => 1000 * 1, - "timeout" => 1000 * 80 - ]; - - public static $turnstileTimeouts = [ - "firstRequestDelay" => 1000 * 1, - "requestInterval" => 1000 * 1, - "timeout" => 1000 * 80 - ]; - - public static $complexImageTimeouts = [ - "firstRequestDelay" => 350, - "requestInterval" => 200, - "timeout" => 1000 * 10 - ]; - - public static function detectTimeouts(string $captchaType) : array { - switch($captchaType) { - case "FunCaptchaTaskProxyless": - case "FunCaptchaTask": - return self::$funCaptchaTimeouts; - case "GeeTestTaskProxyless": - case "GeeTestTask": - return self::$geeTestTimeouts; - case "HCaptchaTaskProxyless": - case "HCaptchaTask": - return self::$hCaptchaTimeouts; - case "ImageToTextTask": - return self::$imageToTextTimeouts; - case "RecaptchaV2EnterpriseTaskProxyless": - case "RecaptchaV2EnterpriseTask": - return self::$recaptchaV2EnterpriseTimeouts; - case "NoCaptchaTaskProxyless": - case "NoCaptchaTask": - return self::$recaptchaV2Timeouts; - case "RecaptchaV3TaskProxyless": - return self::$recaptchaV3Timeouts; - case "TurnstileTask": - case "TurnstileTaskProxyless": - return self::$turnstileTimeouts; - case "ComplexImageTask": - return self::$complexImageTimeouts; - } - } - -} diff --git a/client/src/Validator.php b/client/src/Validator.php deleted file mode 100644 index e374ddb..0000000 --- a/client/src/Validator.php +++ /dev/null @@ -1,52 +0,0 @@ - 0) { - return new Result(false, self::buildFailtureMessage($diff)); - } - - return new Result(true, "valid"); - } - - } diff --git a/client/src/captcha/ComplexImage.php b/client/src/captcha/ComplexImage.php deleted file mode 100644 index 71a1c7b..0000000 --- a/client/src/captcha/ComplexImage.php +++ /dev/null @@ -1,26 +0,0 @@ -clearInput([ - "class" => $_class, - "imageUrls" => $imageUrls, - "imagesBase64" => $imagesBase64, - "metadata" => $metadata, - "userAgent" => $userAgent, - "websiteURL" => $websiteURL, - ]); - CaptchaRequest::__construct('ComplexImageTask', $options); - } -} diff --git a/client/src/captcha/ComplexImageFuncaptcha.php b/client/src/captcha/ComplexImageFuncaptcha.php deleted file mode 100644 index 45a1758..0000000 --- a/client/src/captcha/ComplexImageFuncaptcha.php +++ /dev/null @@ -1,18 +0,0 @@ -_class, $metadata, $imageUrls, $imagesBase64, $userAgent, $websiteURL); - } -} diff --git a/client/src/captcha/ComplexImageHCaptcha.php b/client/src/captcha/ComplexImageHCaptcha.php deleted file mode 100644 index 4ef2bf5..0000000 --- a/client/src/captcha/ComplexImageHCaptcha.php +++ /dev/null @@ -1,18 +0,0 @@ -_class, $metadata, $imageUrls, $imagesBase64, $userAgent, $websiteURL); - } -} diff --git a/client/src/captcha/ComplexImageRecaptcha.php b/client/src/captcha/ComplexImageRecaptcha.php deleted file mode 100644 index f07f040..0000000 --- a/client/src/captcha/ComplexImageRecaptcha.php +++ /dev/null @@ -1,19 +0,0 @@ -_class, $metadata, $imageUrls, $imagesBase64, $userAgent, $websiteURL); - } -} diff --git a/client/src/captcha/FunCaptcha.php b/client/src/captcha/FunCaptcha.php deleted file mode 100644 index 6cf38e9..0000000 --- a/client/src/captcha/FunCaptcha.php +++ /dev/null @@ -1,36 +0,0 @@ -clearInput([ - "websiteURL" => $websiteURL, - "websitePublicKey" => $websitePublicKey, - "userAgent" => $userAgent, - "funcaptchaApiJSSubdomain" => $funcaptchaApiJSSubdomain, - "data" => $data, - "cookies" => $cookies, - "proxyType" => $proxyType, - "proxyAddress" => $proxyAddress, - "proxyPort" => $proxyPort, - "proxyLogin" => $proxyLogin, - "proxyPassword" => $proxyPassword, - ]); - CaptchaRequest::__construct($this->detectProxy($options, "FunCaptchaTask", "FunCaptchaTaskProxyless"), $options); - } - -} \ No newline at end of file diff --git a/client/src/captcha/GeeTest.php b/client/src/captcha/GeeTest.php deleted file mode 100644 index edfd804..0000000 --- a/client/src/captcha/GeeTest.php +++ /dev/null @@ -1,40 +0,0 @@ -clearInput([ - "websiteURL" => $websiteURL, - "gt" => $gt, - "challenge" => $challenge, - "geetestApiServerSubdomain" => $geetestApiServerSubdomain, - "geetestGetLib" => $geetestGetLib, - "version" => $version, - "initParameters" => $initParameters, - "userAgent" => $userAgent, - "proxyType" => $proxyType, - "proxyAddress" => $proxyAddress, - "proxyPort" => $proxyPort, - "proxyLogin" => $proxyLogin, - "proxyPassword" => $proxyPassword, - ]); - CaptchaRequest::__construct($this->detectProxy($options, "GeeTestTask", "GeeTestTaskProxyless"), $options); - } - -} diff --git a/client/src/captcha/HCaptcha.php b/client/src/captcha/HCaptcha.php deleted file mode 100644 index 9c6411a..0000000 --- a/client/src/captcha/HCaptcha.php +++ /dev/null @@ -1,37 +0,0 @@ -clearInput([ - "websiteURL" => $websiteURL, - "websiteKey" => $websiteKey, - "isInvisible" => $isInvisible, - "data" => $data, - "userAgent" => $userAgent, - "cookies" => $cookies, - "proxyType" => $proxyType, - "proxyAddress" => $proxyAddress, - "proxyPort" => $proxyPort, - "proxyLogin" => $proxyLogin, - "proxyPassword" => $proxyPassword, - ]); - CaptchaRequest::__construct($this->detectProxy($options, "HCaptchaTask", "HCaptchaTaskProxyless"), $options); - } - -} \ No newline at end of file diff --git a/client/src/captcha/ImageToText.php b/client/src/captcha/ImageToText.php deleted file mode 100644 index fb85ea0..0000000 --- a/client/src/captcha/ImageToText.php +++ /dev/null @@ -1,20 +0,0 @@ - $body, - "CapMonsterModule" => $CapMonsterModule, - "recognizingThreshold" => $recognizingThreshold, - "Case" => $Case, - "numeric" => $numeric, - "math" => $math - ]; - CaptchaRequest::__construct("ImageToTextTask", $options); - } - -} \ No newline at end of file diff --git a/client/src/captcha/RecaptchaV2.php b/client/src/captcha/RecaptchaV2.php deleted file mode 100644 index 3a01e40..0000000 --- a/client/src/captcha/RecaptchaV2.php +++ /dev/null @@ -1,35 +0,0 @@ -clearInput([ - "websiteURL" => $websiteURL, - "websiteKey" => $websiteKey, - "proxyType" => $proxyType, - "proxyAddress" => $proxyAddress, - "proxyPort" => $proxyPort, - "proxyLogin" => $proxyLogin, - "proxyPassword" => $proxyPassword, - "recaptchaDataSValue" => $recaptchaDataSValue, - "userAgent" => $userAgent, - "cookies" => $cookies - ]); - CaptchaRequest::__construct($this->detectProxy($options, "NoCaptchaTask", "NoCaptchaTaskProxyless"), $options); - } - -} \ No newline at end of file diff --git a/client/src/captcha/RecaptchaV2Enterprise.php b/client/src/captcha/RecaptchaV2Enterprise.php deleted file mode 100644 index d83906d..0000000 --- a/client/src/captcha/RecaptchaV2Enterprise.php +++ /dev/null @@ -1,37 +0,0 @@ -clearInput([ - "websiteURL" => $websiteURL, - "websiteKey" => $websiteKey, - "enterprisePayload" => $enterprisePayload, - "apiDomain" => $apiDomain, - "proxyType" => $proxyType, - "proxyAddress" => $proxyAddress, - "proxyPort" => $proxyPort, - "proxyLogin" => $proxyLogin, - "proxyPassword" => $proxyPassword, - "userAgent" => $userAgent, - "cookies" => $cookies - ]); - CaptchaRequest::__construct($this->detectProxy($options, "RecaptchaV2EnterpriseTask", "RecaptchaV2EnterpriseTaskProxyless"), $options); - } - -} \ No newline at end of file diff --git a/client/src/captcha/RecaptchaV3.php b/client/src/captcha/RecaptchaV3.php deleted file mode 100644 index 3ec256f..0000000 --- a/client/src/captcha/RecaptchaV3.php +++ /dev/null @@ -1,18 +0,0 @@ - $websiteURL, - "websiteKey" => $websiteKey, - "minScore" => $minScore, - "pageAction" => $pageAction - ]; - CaptchaRequest::__construct("RecaptchaV3TaskProxyless", $options); - } - -} \ No newline at end of file diff --git a/client/src/captcha/Turnstile.php b/client/src/captcha/Turnstile.php deleted file mode 100644 index 3309885..0000000 --- a/client/src/captcha/Turnstile.php +++ /dev/null @@ -1,41 +0,0 @@ -clearInput([ - "websiteURL" => $websiteURL, - "websiteKey" => $websiteKey, - "proxyType" => $proxyType, - "proxyAddress" => $proxyAddress, - "proxyPort" => $proxyPort, - "proxyLogin" => $proxyLogin, - "proxyPassword" => $proxyPassword, - "cloudflareTaskType" => $cloudflareTaskType, - "htmlPageBase64" => $htmlPageBase64, - "userAgent" => $userAgent, - "pageAction" => $pageAction, - "data" => $data, - "pageData" => $pageData - ]); - CaptchaRequest::__construct($this->detectProxy($options, "TurnstileTask", "TurnstileTaskProxyless"), $options); - } - -} diff --git a/client/tests/phpunit.xml b/client/tests/phpunit.xml deleted file mode 100644 index 415b3ee..0000000 --- a/client/tests/phpunit.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - test.php - - - \ No newline at end of file diff --git a/client/tests/test.php b/client/tests/test.php deleted file mode 100644 index f280880..0000000 --- a/client/tests/test.php +++ /dev/null @@ -1,246 +0,0 @@ - "https://lessons.zennolab.com/captchas/recaptcha/v2_simple.php?level=high", - "websiteKey" => "6Lcg7CMUAAAAANphynKgn9YAgA4tQ2KI_iqRyTwd" - ]; - $request = new RecaptchaV2Request($captchaOptions["websiteURL"], $captchaOptions["websiteKey"]); - - $solution = $client->solve($request); - if(gettype($solution->message) == 'array') { - $solution->setMessage(json_encode($solution->message)); - } - $this->assertTrue($solution->result, $solution->message); - } - - public function testHcaptchaSolve() { - global $clientKey; - - $client = new Client($clientKey); - - $captchaOptions = [ - "websiteURL" => "https://lessons.zennolab.com/captchas/hcaptcha/?level=easy", - "websiteKey" => "472fc7af-86a4-4382-9a49-ca9090474471" - ]; - $request = new HCaptchaRequest($captchaOptions["websiteURL"], $captchaOptions["websiteKey"]); - - $solution = $client->solve($request); - if(gettype($solution->message) == 'array') { - $solution->setMessage(json_encode($solution->message)); - } - $this->assertTrue($solution->result, $solution->message); - } - - public function testIncorrectWebsite() { - global $clientKey; - - $client = new Client($clientKey); - - $captchaOptions = [ - "websiteURL" => "incorrect website", - "websiteKey" => "6Lcg7CMUAAAAANphynKgn9YAgA4tQ2KI_iqRyTwd" - ]; - $request = new HCaptchaRequest($captchaOptions["websiteURL"], $captchaOptions["websiteKey"]); - - $solution = $client->solve($request); - if(gettype($solution->message) == 'array') { - $solution->setMessage(json_encode($solution->message)); - } - $this->assertFalse($solution->result, $solution->message); - } - - public function testIncorrectMinScore() { - global $clientKey; - - $client = new Client($clientKey); - - $captchaOptions = [ - "websiteURL" => "https://lessons.zennolab.com/captchas/recaptcha/v2_simple.php?level=high", - "websiteKey" => "6Lcg7CMUAAAAANphynKgn9YAgA4tQ2KI_iqRyTwd", - "minScore" => 1.1 - ]; - $request = new RecaptchaV3Request($captchaOptions["websiteURL"], $captchaOptions["websiteKey"], $captchaOptions["minScore"]); - - $solution = $client->solve($request); - if(gettype($solution->message) == 'array') { - $solution->setMessage(json_encode($solution->message)); - } - $this->assertTrue($solution->result, $solution->message); - } - - public function testIncorrectRecognizingThreshold() { - global $clientKey; - - $client = new Client($clientKey); - - $captchaOptions = [ - "body" => "body", - "recognizingThreshold" => 101 - ]; - $request = new ImageToTextRequest($captchaOptions['body'], null, $captchaOptions["recognizingThreshold"]); - - $solution = $client->solve($request); - if(gettype($solution->message) == 'array') { - $solution->setMessage(json_encode($solution->message)); - } - $this->assertFalse($solution->result, $solution->message); - } - - public function testIncorrectWebsiteKey() { - global $clientKey; - - $client = new Client($clientKey); - - $captchaOptions = [ - "websiteURL" => "https://lessons.zennolab.com/captchas/recaptcha/v2_simple.php?level=high", - "websiteKey" => "" - ]; - $request = new RecaptchaV2Request($captchaOptions["websiteURL"], $captchaOptions["websiteKey"]); - - $solution = $client->solve($request); - if(gettype($solution->message) == 'array') { - $solution->setMessage(json_encode($solution->message)); - } - $this->assertFalse($solution->result, $solution->message); - } - - public function testIncorrectGt() { - global $clientKey; - - $client = new Client($clientKey); - - $captchaOptions = [ - "websiteURL" => "https://lessons.zennolab.com/captchas/recaptcha/v2_simple.php?level=high", - "gt" => "", - "challenge" => "" - ]; - $request = new GeeTestRequest($captchaOptions["websiteURL"], $captchaOptions["gt"], $captchaOptions["challenge"]); - - $solution = $client->solve($request); - if(gettype($solution->message) == 'array') { - $solution->setMessage(json_encode($solution->message)); - } - $this->assertFalse($solution->result, $solution->message); - } - - public function testGeeTesV4() { - global $clientKey; - - $client = new Client($clientKey); - - $captchaOptions = [ - "websiteURL" => "https://faucetpay.io/account/login", - "gt" => "eb8b0c2b27f3365b9244d9da81638c6", - "version" => "4", - "initParameters" => [ - "riskType" => "slide" - ], - "userAgent" => "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36" - ]; - $request = new GeeTestRequest( - $captchaOptions["websiteURL"], - $captchaOptions["gt"], - null, - null, - null, - $captchaOptions["version"], - $captchaOptions["initParameters"], - $captchaOptions["userAgent"] - ); - - $solution = $client->solve($request); - if(gettype($solution->message) == 'array') { - $solution->setMessage(json_encode($solution->message)); - } - $this->assertTrue($solution->result, $solution->message); - } - - public function testTurnstileSolve() { - global $clientKey; - - $client = new Client($clientKey); - - $captchaOptions = [ - "websiteURL" => "https://tsinvisble.zlsupport.com", - "websiteKey" => "0x4AAAAAAABUY0VLtOUMAHxE" - ]; - $request = new TurnstileRequest($captchaOptions["websiteURL"], $captchaOptions["websiteKey"]); - - $solution = $client->solve($request); - if(gettype($solution->message) == 'array') { - $solution->setMessage(json_encode($solution->message)); - } - $this->assertTrue($solution->result, $solution->message); - } - - public function testComplexImageHCaptchaSolve() - { - global $clientKey; - - $client = new Client($clientKey); - - $captchaOptions = [ - "imageUrls" => [ - "https://i.postimg.cc/kg71cbRt/image-1.jpg" - ], - "metadata" => [ - "Task" => "Please click each image containing a mountain" - ], - ]; - $request = new ComplexImageHCaptchaRequest($captchaOptions['metadata'], $captchaOptions['imageUrls']); - - $solution = $client->solve($request); - if(gettype($solution->message) == 'array') { - $solution->setMessage(json_encode($solution->message)); - } - $this->assertTrue($solution->result, $solution->message); - } - - public function textComplexImageRecaptchaSolve() - { - global $clientKey; - - $client = new Client($clientKey); - - $captchaOptions = [ - "imageUrls" => [ - "https://i.postimg.cc/s2ZDrHXy/fc1.jpg" - ], - "metadata" => [ - "Task" => "Pick the image that is the correct way up" - ], - ]; - $request = new ComplexImageFuncaptchaRequest($captchaOptions['metadata'], $captchaOptions['imageUrls']); - - $solution = $client->solve($request); - if(gettype($solution->message) == 'array') { - $solution->setMessage(json_encode($solution->message)); - } - $this->assertTrue($solution->result, $solution->message); - } - - } diff --git a/composer.json b/composer.json index d8ccd5b..4db3dae 100644 --- a/composer.json +++ b/composer.json @@ -1,7 +1,36 @@ { "name": "zennolab/capmonstercloud.client", - "description": "API client for capmonster.cloud", + "description": "PHP client for the CapMonster Cloud captcha-solving API", + "type": "library", + "license": "MIT", + "keywords": ["captcha", "capmonster", "recaptcha", "turnstile", "captcha-solver"], + "homepage": "https://capmonster.cloud", + "support": { + "docs": "https://docs.capmonster.cloud", + "source": "https://github.com/CapMonsterCloud/capmonster-php-captcha-solver" + }, "require": { - "phpunit/phpunit": "^7.5" + "php": ">=8.1", + "ext-curl": "*", + "ext-json": "*" + }, + "require-dev": { + "phpunit/phpunit": "^10.5 || ^11.0" + }, + "autoload": { + "psr-4": { + "Zennolab\\CapMonsterCloud\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Zennolab\\CapMonsterCloud\\Tests\\": "tests/" + } + }, + "scripts": { + "test": "phpunit" + }, + "config": { + "sort-packages": true } } diff --git a/examples/image_to_text.php b/examples/image_to_text.php new file mode 100644 index 0000000..a28fb57 --- /dev/null +++ b/examples/image_to_text.php @@ -0,0 +1,14 @@ +solve($request)['text'], "\n"; diff --git a/examples/low_level.php b/examples/low_level.php new file mode 100644 index 0000000..77862b8 --- /dev/null +++ b/examples/low_level.php @@ -0,0 +1,26 @@ + 'slide']); +$taskId = $client->createTask($request); + +$schedule = $request->timeouts(); +usleep((int) ($schedule->firstRequestDelay * 1e6)); +for ($waited = 0.0; $waited < $schedule->timeout; $waited += $schedule->requestsInterval) { + $result = $client->getTaskResult($taskId); + if ($result->isReady) { + print_r($result->solution); + exit(0); + } + usleep((int) ($schedule->requestsInterval * 1e6)); +} +fwrite(STDERR, "timeout\n"); +exit(1); diff --git a/examples/recaptcha_v2.php b/examples/recaptcha_v2.php new file mode 100644 index 0000000..de3920c --- /dev/null +++ b/examples/recaptcha_v2.php @@ -0,0 +1,24 @@ +getBalance()); + + $solution = $client->solve(new RecaptchaV2Request( + 'https://lessons.zennolab.com/captchas/recaptcha/v2_simple.php?level=high', + '6Lcg7CMUAAAAANphynKgn9YAgA4tQ2KI_iqRyTwd', + )); + echo 'gRecaptchaResponse: ', $solution['gRecaptchaResponse'], "\n"; +} catch (CapMonsterException $e) { + fwrite(STDERR, get_class($e) . ': ' . $e->getMessage() . "\n"); + exit(1); +} diff --git a/examples/turnstile_with_proxy.php b/examples/turnstile_with_proxy.php new file mode 100644 index 0000000..dfc68ea --- /dev/null +++ b/examples/turnstile_with_proxy.php @@ -0,0 +1,23 @@ +solve(TurnstileRequest::standard( + 'https://target-website.com/login', + '0x4AAAAAAABnPIDnK2k_e-2', + proxy: $proxy, +)); +// put $solution['token'] into the cf-turnstile-response field and submit the form +echo 'Turnstile token: ', $solution['token'], "\n"; diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..db1b696 --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,19 @@ + + + + + tests + + + + + src + + + diff --git a/src/CapMonsterClient.php b/src/CapMonsterClient.php new file mode 100644 index 0000000..e20507f --- /dev/null +++ b/src/CapMonsterClient.php @@ -0,0 +1,184 @@ +transport = $transport ?? new CurlTransport( + 'Zennolab.CapMonsterCloud.Client.PHP/' . self::VERSION, + $options->clientTimeout, + $options->clientProxy, + ); + } + + public function options(): ClientOptions + { + return $this->options; + } + + /** + * Account balance in USD. + * + * @throws CapMonsterException + */ + public function getBalance(): float + { + $response = $this->call('getBalance', ['clientKey' => $this->options->apiKey]); + if ((int) $response['errorId'] !== 0) { + throw new GetBalanceException('getBalance', ...self::error($response)); + } + if (!isset($response['balance']) || !is_numeric($response['balance'])) { + throw new MalformedResponseException('getBalance: no `balance` field'); + } + + return (float) $response['balance']; + } + + /** + * Creates a task and returns its id (low-level; most users want {@see solve()}). + * + * @throws CapMonsterException + */ + public function createTask(CaptchaRequest $request): int + { + $response = $this->call('createTask', [ + 'clientKey' => $this->options->apiKey, + 'task' => $request->toTask(), + 'softId' => $this->options->softId, + ]); + if ((int) $response['errorId'] !== 0) { + throw new CreateTaskException('createTask', ...self::error($response)); + } + if (!isset($response['taskId']) || !is_numeric($response['taskId'])) { + throw new MalformedResponseException('createTask: no `taskId` field'); + } + + return (int) $response['taskId']; + } + + /** + * One `getTaskResult` call (low-level). + * + * @throws CapMonsterException + */ + public function getTaskResult(int $taskId): TaskResult + { + try { + $response = $this->call('getTaskResult', ['clientKey' => $this->options->apiKey, 'taskId' => $taskId]); + } catch (UnexpectedHttpStatusException $e) { + if (in_array($e->status, self::PROCESSING_STATUSES, true)) { + return TaskResult::processing(); + } + throw $e; + } + if ((int) $response['errorId'] !== 0) { + throw new GetResultException('getTaskResult', ...self::error($response)); + } + if (($response['status'] ?? null) !== 'ready') { + return TaskResult::processing(); + } + $solution = $response['solution'] ?? []; + + return TaskResult::ready(is_array($solution) ? $solution : ['value' => $solution]); + } + + /** + * Creates a task, polls until it is solved and returns the `solution` object + * (e.g. `['gRecaptchaResponse' => '...']`, `['token' => '...']`, `['text' => '...']`). + * + * @return array + * + * @throws TimeoutException if the captcha type's maximum waiting time is exceeded + * @throws CapMonsterException on any other failure + */ + public function solve(CaptchaRequest $request): array + { + $timeouts = $this->options->timeoutsOverride ?? $request->timeouts(); + $taskId = $this->createTask($request); + + $deadline = self::now() + $timeouts->timeout; + self::sleep($timeouts->firstRequestDelay); + while (self::now() < $deadline) { + $result = $this->getTaskResult($taskId); + if ($result->isReady) { + return $result->solution ?? []; + } + self::sleep(min($timeouts->requestsInterval, max(0.0, $deadline - self::now()))); + } + + throw new TimeoutException($taskId, $timeouts->timeout); + } + + /** + * @param array $body + * + * @return array decoded JSON with at least `errorId` + */ + private function call(string $method, array $body): array + { + $url = rtrim($this->options->serviceUrl, '/') . '/' . $method; + $response = $this->transport->post($url, $body); + + $data = json_decode($response->body, true); + $isEnvelope = is_array($data) && array_key_exists('errorId', $data); + $isSuccess = $response->status >= 200 && $response->status < 300; + + // An error JSON (errorId != 0) is reported as an API error whatever the HTTP status is. + if ($isEnvelope && ($isSuccess || (int) $data['errorId'] !== 0)) { + return $data; + } + if (!$isSuccess) { + throw new UnexpectedHttpStatusException($method, $response->status); + } + + throw new MalformedResponseException(sprintf('%s: unexpected response body: %.200s', $method, $response->body)); + } + + /** + * @param array $response + * + * @return array{0: string, 1: string} errorCode, errorDescription + */ + private static function error(array $response): array + { + return [(string) ($response['errorCode'] ?? ''), (string) ($response['errorDescription'] ?? '')]; + } + + private static function now(): float + { + return hrtime(true) / 1e9; + } + + private static function sleep(float $seconds): void + { + if ($seconds > 0) { + usleep((int) ($seconds * 1_000_000)); + } + } +} diff --git a/src/ClientOptions.php b/src/ClientOptions.php new file mode 100644 index 0000000..f142739 --- /dev/null +++ b/src/ClientOptions.php @@ -0,0 +1,40 @@ + '***', + 'serviceUrl' => $this->serviceUrl, + 'softId' => $this->softId, + 'clientTimeout' => $this->clientTimeout, + 'clientProxy' => $this->clientProxy, + 'timeoutsOverride' => $this->timeoutsOverride, + ]; + } +} diff --git a/src/ClientProxy.php b/src/ClientProxy.php new file mode 100644 index 0000000..acedfb4 --- /dev/null +++ b/src/ClientProxy.php @@ -0,0 +1,38 @@ + 65535) { + throw new ValidationException("client proxy port must be between 1 and 65535, got {$port}"); + } + } + + /** Keeps the password out of var_dump()/print_r() output. */ + public function __debugInfo(): array + { + return [ + 'type' => $this->type, + 'address' => $this->address, + 'port' => $this->port, + 'login' => $this->login, + 'password' => $this->password === null ? null : '***', + ]; + } +} diff --git a/src/Exception/ApiException.php b/src/Exception/ApiException.php new file mode 100644 index 0000000..b591ea0 --- /dev/null +++ b/src/Exception/ApiException.php @@ -0,0 +1,20 @@ +getMessage(), 0, $e); + } + + $this->handle ??= curl_init(); + $ch = $this->handle; + curl_reset($ch); + $options = [ + CURLOPT_URL => $url, + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => $json, + CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'Accept: application/json'], + CURLOPT_RETURNTRANSFER => true, + CURLOPT_USERAGENT => $this->userAgent, + CURLOPT_TIMEOUT_MS => (int) ($this->timeout * 1000), + CURLOPT_CONNECTTIMEOUT_MS => (int) (min($this->timeout, 10.0) * 1000), + ]; + if ($this->proxy !== null) { + $options[CURLOPT_PROXY] = $this->proxy->address; + $options[CURLOPT_PROXYPORT] = $this->proxy->port; + $options[CURLOPT_PROXYTYPE] = match ($this->proxy->type) { + ProxyType::Http => CURLPROXY_HTTP, + ProxyType::Https => CURLPROXY_HTTPS, + ProxyType::Socks4 => CURLPROXY_SOCKS4, + ProxyType::Socks5 => CURLPROXY_SOCKS5_HOSTNAME, + }; + if ($this->proxy->login !== null) { + // separate options instead of PROXYUSERPWD: a ':' inside the login stays intact + $options[CURLOPT_PROXYUSERNAME] = $this->proxy->login; + $options[CURLOPT_PROXYPASSWORD] = $this->proxy->password ?? ''; + } + } + curl_setopt_array($ch, $options); + + $response = curl_exec($ch); + if ($response === false) { + throw new TransportException(sprintf('curl error %d: %s', curl_errno($ch), curl_error($ch))); + } + + return new HttpResponse((int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE), (string) $response); + } +} diff --git a/src/Http/HttpResponse.php b/src/Http/HttpResponse.php new file mode 100644 index 0000000..739b09e --- /dev/null +++ b/src/Http/HttpResponse.php @@ -0,0 +1,14 @@ + $body Encoded as JSON. + * + * @throws TransportException on network-level failures. Non-2xx statuses are NOT errors here. + */ + public function post(string $url, array $body): HttpResponse; +} diff --git a/src/ProxyInfo.php b/src/ProxyInfo.php new file mode 100644 index 0000000..c4bf6fb --- /dev/null +++ b/src/ProxyInfo.php @@ -0,0 +1,57 @@ + 65535) { + throw new ValidationException("proxy port must be between 1 and 65535, got {$port}"); + } + } + + /** @return array */ + public function toArray(): array + { + return array_filter([ + 'proxyType' => $this->type->value, + 'proxyAddress' => $this->address, + 'proxyPort' => $this->port, + 'proxyLogin' => $this->login, + 'proxyPassword' => $this->password, + ], static fn ($v) => $v !== null); + } + + /** Keeps the password out of var_dump()/print_r() output. */ + public function __debugInfo(): array + { + return [ + 'type' => $this->type, + 'address' => $this->address, + 'port' => $this->port, + 'login' => $this->login, + 'password' => $this->password === null ? null : '***', + ]; + } +} diff --git a/src/ProxyType.php b/src/ProxyType.php new file mode 100644 index 0000000..f7d5c07 --- /dev/null +++ b/src/ProxyType.php @@ -0,0 +1,14 @@ +validate(); + $task = ['type' => static::TASK_TYPE]; + if (static::TASK_CLASS !== null) { + $task['class'] = static::TASK_CLASS; + } + + return $task + self::withoutNulls($this->fields()); + } + + /** @return array */ + abstract protected function fields(): array; + + /** @throws ValidationException */ + protected function validate(): void + { + } + + /** @throws ValidationException */ + protected static function requireNonEmpty(string $field, ?string $value): void + { + if ($value === null || $value === '') { + throw new ValidationException("{$field} must not be empty"); + } + } + + /** @return array */ + protected static function proxyFields(?ProxyInfo $proxy): array + { + return $proxy?->toArray() ?? []; + } + + /** + * @param array $values + * + * @return array + */ + protected static function withoutNulls(array $values): array + { + return array_filter($values, static fn ($v) => $v !== null); + } +} diff --git a/src/Requests/AlibabaCustomTaskRequest.php b/src/Requests/AlibabaCustomTaskRequest.php new file mode 100644 index 0000000..cbd1c17 --- /dev/null +++ b/src/Requests/AlibabaCustomTaskRequest.php @@ -0,0 +1,82 @@ +websiteUrl); + self::requireNonEmpty('metadata.sceneId', $this->sceneId); + self::requireNonEmpty('metadata.prefix', $this->prefix); + } + + protected function fields(): array + { + return [ + 'websiteURL' => $this->websiteUrl, + 'metadata' => self::withoutNulls([ + 'sceneId' => $this->sceneId, + 'prefix' => $this->prefix, + 'userId' => $this->userId, + 'userUserId' => $this->userUserId, + 'verifyType' => $this->verifyType, + 'region' => $this->region, + 'UserCertifyId' => $this->userCertifyId, + 'apiGetLib' => $this->apiGetLib, + 'punishUrl' => $this->punishUrl, + 'cookieRequired' => $this->cookieRequired, + ]), + 'userAgent' => $this->userAgent, + ] + self::proxyFields($this->proxy); + } +} diff --git a/src/Requests/AltchaCustomTaskRequest.php b/src/Requests/AltchaCustomTaskRequest.php new file mode 100644 index 0000000..ad929fa --- /dev/null +++ b/src/Requests/AltchaCustomTaskRequest.php @@ -0,0 +1,60 @@ +websiteUrl); + self::requireNonEmpty('metadata.challenge', $this->challenge); + self::requireNonEmpty('metadata.iterations', $this->iterations); + self::requireNonEmpty('metadata.salt', $this->salt); + self::requireNonEmpty('metadata.signature', $this->signature); + } + + protected function fields(): array + { + return [ + 'websiteURL' => $this->websiteUrl, + 'websiteKey' => $this->websiteKey, + 'metadata' => [ + 'challenge' => $this->challenge, + 'iterations' => $this->iterations, + 'salt' => $this->salt, + 'signature' => $this->signature, + ], + 'userAgent' => $this->userAgent, + ] + self::proxyFields($this->proxy); + } +} diff --git a/src/Requests/AmazonWafRequest.php b/src/Requests/AmazonWafRequest.php new file mode 100644 index 0000000..da5519a --- /dev/null +++ b/src/Requests/AmazonWafRequest.php @@ -0,0 +1,132 @@ +jsapi.js`). + * @param string|null $userAgent Only this option accepts a User-Agent. + */ + public static function visibleCaptcha( + string $websiteUrl, + string $websiteKey, + string $captchaScript, + ?ProxyInfo $proxy = null, + ?string $userAgent = null, + ?bool $cookieSolution = null, + ): self { + return new self(self::VISIBLE, $websiteUrl, $proxy, $cookieSolution, $websiteKey, $captchaScript, userAgent: $userAgent); + } + + /** + * Option 2: challenge plus captcha. The values come from `window.gokuProps` on the 405 page of the site. + * + * @param string $challengeScript Link to `challenge.js`. + * @param string $websiteKey `window.gokuProps.key`. + * @param string $context `window.gokuProps.context`. + * @param string $iv `window.gokuProps.iv`. + * @param string|null $captchaScript Link to `captcha.js`; may be absent if only a challenge is present. + */ + public static function challengeAndCaptcha( + string $websiteUrl, + string $challengeScript, + string $websiteKey, + string $context, + string $iv, + ?string $captchaScript = null, + ?ProxyInfo $proxy = null, + ?bool $cookieSolution = null, + ): self { + return new self(self::CHALLENGE_AND_CAPTCHA, $websiteUrl, $proxy, $cookieSolution, $websiteKey, $captchaScript, $challengeScript, $context, $iv); + } + + /** + * Option 3: invisible captcha, only `challenge.js` is loaded. `context` and `iv` are sent as empty strings, + * as the API requires. + */ + public static function challengeOnly( + string $websiteUrl, + string $challengeScript, + ?ProxyInfo $proxy = null, + ?bool $cookieSolution = null, + ): self { + return new self(self::CHALLENGE_ONLY, $websiteUrl, $proxy, $cookieSolution, challengeScript: $challengeScript, context: '', iv: ''); + } + + public function timeouts(): GetResultTimeouts + { + return new GetResultTimeouts(1, 3, 180); + } + + protected function validate(): void + { + self::requireNonEmpty('websiteURL', $this->websiteUrl); + switch ($this->variant) { + case self::VISIBLE: + self::requireNonEmpty('websiteKey', $this->websiteKey); + self::requireNonEmpty('captchaScript', $this->captchaScript); + break; + case self::CHALLENGE_AND_CAPTCHA: + self::requireNonEmpty('challengeScript', $this->challengeScript); + self::requireNonEmpty('websiteKey', $this->websiteKey); + self::requireNonEmpty('context', $this->context); + self::requireNonEmpty('iv', $this->iv); + break; + case self::CHALLENGE_ONLY: + // `context` and `iv` are "" by design for this variant + self::requireNonEmpty('challengeScript', $this->challengeScript); + break; + } + } + + protected function fields(): array + { + return [ + 'websiteURL' => $this->websiteUrl, + 'websiteKey' => $this->websiteKey, + 'captchaScript' => $this->captchaScript, + 'challengeScript' => $this->challengeScript, + 'context' => $this->context, + 'iv' => $this->iv, + 'userAgent' => $this->userAgent, + 'cookieSolution' => $this->cookieSolution, + ] + self::proxyFields($this->proxy); + } +} diff --git a/src/Requests/BasiliskCustomTaskRequest.php b/src/Requests/BasiliskCustomTaskRequest.php new file mode 100644 index 0000000..1353936 --- /dev/null +++ b/src/Requests/BasiliskCustomTaskRequest.php @@ -0,0 +1,47 @@ +websiteUrl); + self::requireNonEmpty('websiteKey', $this->websiteKey); + } + + protected function fields(): array + { + return [ + 'websiteURL' => $this->websiteUrl, + 'websiteKey' => $this->websiteKey, + 'userAgent' => $this->userAgent, + ] + self::proxyFields($this->proxy); + } +} diff --git a/src/Requests/BinanceTaskRequest.php b/src/Requests/BinanceTaskRequest.php new file mode 100644 index 0000000..f07da57 --- /dev/null +++ b/src/Requests/BinanceTaskRequest.php @@ -0,0 +1,53 @@ +websiteUrl); + self::requireNonEmpty('websiteKey', $this->websiteKey); + self::requireNonEmpty('validateId', $this->validateId); + } + + protected function fields(): array + { + return [ + 'websiteURL' => $this->websiteUrl, + 'websiteKey' => $this->websiteKey, + 'validateId' => $this->validateId, + 'userAgent' => $this->userAgent, + ] + self::proxyFields($this->proxy); + } +} diff --git a/src/Requests/CaptchaRequest.php b/src/Requests/CaptchaRequest.php new file mode 100644 index 0000000..9a8cbc9 --- /dev/null +++ b/src/Requests/CaptchaRequest.php @@ -0,0 +1,29 @@ + + * + * @throws ValidationException if the request is inconsistent + */ + public function toTask(): array; +} diff --git a/src/Requests/ComplexImage.php b/src/Requests/ComplexImage.php new file mode 100644 index 0000000..524ded6 --- /dev/null +++ b/src/Requests/ComplexImage.php @@ -0,0 +1,52 @@ + $max) { + throw new ValidationException("{$field}: at most {$max} entries allowed, got {$count}"); + } + foreach ($list as $i => $item) { + if (!is_string($item) || $item === '') { + throw new ValidationException("{$field}[{$i}] must be a non-empty string"); + } + } + } + + private function __construct() + { + } +} diff --git a/src/Requests/DataDomeCustomTaskRequest.php b/src/Requests/DataDomeCustomTaskRequest.php new file mode 100644 index 0000000..6f14ace --- /dev/null +++ b/src/Requests/DataDomeCustomTaskRequest.php @@ -0,0 +1,65 @@ +websiteUrl); + self::requireNonEmpty('metadata.captchaUrl', $this->captchaUrl); + self::requireNonEmpty('metadata.datadomeCookie', $this->datadomeCookie); + } + + protected function fields(): array + { + return [ + 'websiteURL' => $this->websiteUrl, + 'metadata' => self::withoutNulls([ + 'captchaUrl' => $this->captchaUrl, + 'datadomeCookie' => $this->datadomeCookie, + 'datadomeVersion' => $this->datadomeVersion, + ]), + 'userAgent' => $this->userAgent, + ] + self::proxyFields($this->proxy); + } +} diff --git a/src/Requests/FriendlyCustomTaskRequest.php b/src/Requests/FriendlyCustomTaskRequest.php new file mode 100644 index 0000000..3a61c80 --- /dev/null +++ b/src/Requests/FriendlyCustomTaskRequest.php @@ -0,0 +1,53 @@ +websiteUrl); + self::requireNonEmpty('websiteKey', $this->websiteKey); + self::requireNonEmpty('metadata.apiGetLib', $this->apiGetLib); + } + + protected function fields(): array + { + return [ + 'websiteURL' => $this->websiteUrl, + 'websiteKey' => $this->websiteKey, + 'metadata' => ['apiGetLib' => $this->apiGetLib], + 'userAgent' => $this->userAgent, + ] + self::proxyFields($this->proxy); + } +} diff --git a/src/Requests/FuncaptchaRequest.php b/src/Requests/FuncaptchaRequest.php new file mode 100644 index 0000000..dbf2ff8 --- /dev/null +++ b/src/Requests/FuncaptchaRequest.php @@ -0,0 +1,59 @@ +websiteUrl); + self::requireNonEmpty('websitePublicKey', $this->websitePublicKey); + } + + protected function fields(): array + { + return [ + 'websiteURL' => $this->websiteUrl, + 'websitePublicKey' => $this->websitePublicKey, + 'funcaptchaApiJSSubdomain' => $this->funcaptchaApiJsSubdomain, + 'data' => $this->data, + 'cookies' => $this->cookies, + 'userAgent' => $this->userAgent, + ] + self::proxyFields($this->proxy); + } +} diff --git a/src/Requests/GeetestRequest.php b/src/Requests/GeetestRequest.php new file mode 100644 index 0000000..fb99248 --- /dev/null +++ b/src/Requests/GeetestRequest.php @@ -0,0 +1,102 @@ +|null $initParameters */ + private function __construct( + public readonly string $websiteUrl, + public readonly string $gt, + public readonly int $version, + public readonly ?string $challenge, + public readonly ?array $initParameters, + public readonly ?ProxyInfo $proxy, + public readonly ?string $geetestApiServerSubdomain, + public readonly ?string $geetestGetLib, + public readonly ?string $userAgent, + ) { + } + + /** + * GeeTest v3. + * + * @param string $websiteUrl URL of the page where the captcha is initialized (usually the `Referer` of the + * `gettype.php` request, which may differ from the URL in the address bar). + * @param string $gt The `gt` key of the domain. + * @param string $challenge Dynamic key: obtain a fresh one before creating each task and before the captcha + * is initialized on the page (afterwards it becomes invalid). + */ + public static function v3( + string $websiteUrl, + string $gt, + string $challenge, + ?ProxyInfo $proxy = null, + ?string $geetestApiServerSubdomain = null, + ?string $geetestGetLib = null, + ?string $userAgent = null, + ): self { + return new self($websiteUrl, $gt, 3, $challenge, null, $proxy, $geetestApiServerSubdomain, $geetestGetLib, $userAgent); + } + + /** + * GeeTest v4. + * + * @param string $gt The `captcha_id`. + * @param array|null $initParameters Extra v4 parameters, e.g. `['riskType' => 'slide']`. + */ + public static function v4( + string $websiteUrl, + string $gt, + ?array $initParameters = null, + ?ProxyInfo $proxy = null, + ?string $geetestApiServerSubdomain = null, + ?string $geetestGetLib = null, + ?string $userAgent = null, + ): self { + return new self($websiteUrl, $gt, 4, null, $initParameters ?: null, $proxy, $geetestApiServerSubdomain, $geetestGetLib, $userAgent); + } + + public function timeouts(): GetResultTimeouts + { + return new GetResultTimeouts(1, 1, 80); + } + + protected function validate(): void + { + self::requireNonEmpty('websiteURL', $this->websiteUrl); + self::requireNonEmpty('gt', $this->gt); + if ($this->version === 3) { + self::requireNonEmpty('challenge', $this->challenge); + } + } + + protected function fields(): array + { + return [ + 'websiteURL' => $this->websiteUrl, + 'gt' => $this->gt, + 'version' => $this->version, + 'challenge' => $this->challenge, + 'initParameters' => $this->initParameters, + 'geetestApiServerSubdomain' => $this->geetestApiServerSubdomain, + 'geetestGetLib' => $this->geetestGetLib, + 'userAgent' => $this->userAgent, + ] + self::proxyFields($this->proxy); + } +} diff --git a/src/Requests/HuntCustomTaskRequest.php b/src/Requests/HuntCustomTaskRequest.php new file mode 100644 index 0000000..436e6bc --- /dev/null +++ b/src/Requests/HuntCustomTaskRequest.php @@ -0,0 +1,83 @@ +/api.js`. Values are dynamic: extract them right before + * creating the task. + */ +final class HuntCustomTaskRequest extends AbstractRequest +{ + protected const TASK_TYPE = 'CustomTask'; + protected const TASK_CLASS = 'HUNT'; + + private function __construct( + public readonly string $websiteUrl, + public readonly string $apiGetLib, + public readonly ProxyInfo $proxy, + public readonly ?string $data, + public readonly ?string $widgetUrl, + public readonly ?string $userAgent, + private readonly string $mode, + ) { + } + + /** Only generate a fingerprint (just `apiGetLib` is sent). */ + public static function fingerprint(string $websiteUrl, string $apiGetLib, ProxyInfo $proxy, ?string $userAgent = null): self + { + return new self($websiteUrl, $apiGetLib, $proxy, null, null, $userAgent, 'fingerprint'); + } + + /** Solve the captcha via the `meta.token` value received from the website (`metadata.data`). */ + public static function byToken(string $websiteUrl, string $apiGetLib, string $data, ProxyInfo $proxy, ?string $userAgent = null): self + { + return new self($websiteUrl, $apiGetLib, $proxy, $data, null, $userAgent, 'token'); + } + + /** + * Solve the captcha via the full URL of the HUNT widget, including domain, path and query (`metadata.widgetUrl`), + * e.g. `https://captcha.example.com/widget?hash=`. + */ + public static function byWidgetUrl(string $websiteUrl, string $apiGetLib, string $widgetUrl, ProxyInfo $proxy, ?string $userAgent = null): self + { + return new self($websiteUrl, $apiGetLib, $proxy, null, $widgetUrl, $userAgent, 'widget_url'); + } + + public function timeouts(): GetResultTimeouts + { + return new GetResultTimeouts(1, 3, 180); + } + + protected function validate(): void + { + self::requireNonEmpty('websiteURL', $this->websiteUrl); + self::requireNonEmpty('metadata.apiGetLib', $this->apiGetLib); + if ($this->mode === 'token') { + self::requireNonEmpty('metadata.data', $this->data); + } elseif ($this->mode === 'widget_url') { + self::requireNonEmpty('metadata.widgetUrl', $this->widgetUrl); + } + } + + protected function fields(): array + { + return [ + 'websiteURL' => $this->websiteUrl, + 'metadata' => self::withoutNulls([ + 'apiGetLib' => $this->apiGetLib, + 'data' => $this->data, + 'widgetUrl' => $this->widgetUrl, + ]), + 'userAgent' => $this->userAgent, + ] + self::proxyFields($this->proxy); + } +} diff --git a/src/Requests/ImageToTextRequest.php b/src/Requests/ImageToTextRequest.php new file mode 100644 index 0000000..7d988f6 --- /dev/null +++ b/src/Requests/ImageToTextRequest.php @@ -0,0 +1,82 @@ + ...]`. */ +final class ImageToTextRequest extends AbstractRequest +{ + protected const TASK_TYPE = 'ImageToTextTask'; + + /** + * @param string $body Base64-encoded image, one line, without a `data:image/...;base64,` prefix. + * Use {@see fromFile()} to read an image from disk. + * @param string|null $capMonsterModule Recognition module, see {@see TextModule}. + * @param int|null $recognizingThreshold Minimum recognition confidence, 0..100. + * @param bool|null $case Case-sensitive text. + * @param bool|null $numeric Only digits (sent as 0/1). + * @param bool|null $math The image contains an arithmetic expression to calculate. + */ + public function __construct( + public readonly string $body, + public readonly ?string $capMonsterModule = null, + public readonly ?int $recognizingThreshold = null, + public readonly ?bool $case = null, + public readonly ?bool $numeric = null, + public readonly ?bool $math = null, + ) { + } + + /** + * Reads the image file and base64-encodes it. Optional arguments are those of the constructor. + * + * @throws ValidationException if the file cannot be read + */ + public static function fromFile( + string $path, + ?string $capMonsterModule = null, + ?int $recognizingThreshold = null, + ?bool $case = null, + ?bool $numeric = null, + ?bool $math = null, + ): self { + $bytes = @file_get_contents($path); + if ($bytes === false) { + throw new ValidationException("cannot read image file {$path}"); + } + + return new self(base64_encode($bytes), $capMonsterModule, $recognizingThreshold, $case, $numeric, $math); + } + + public function timeouts(): GetResultTimeouts + { + return new GetResultTimeouts(0.35, 0.2, 10); + } + + protected function validate(): void + { + self::requireNonEmpty('body', $this->body); + if (str_starts_with($this->body, 'data:')) { + throw new ValidationException('body must be plain base64, without the "data:...;base64," prefix'); + } + if ($this->recognizingThreshold !== null && ($this->recognizingThreshold < 0 || $this->recognizingThreshold > 100)) { + throw new ValidationException("recognizingThreshold must be between 0 and 100, got {$this->recognizingThreshold}"); + } + } + + protected function fields(): array + { + return [ + 'body' => $this->body, + 'capMonsterModule' => $this->capMonsterModule, + 'recognizingThreshold' => $this->recognizingThreshold, + 'case' => $this->case, + 'numeric' => $this->numeric === null ? null : (int) $this->numeric, + 'math' => $this->math, + ]; + } +} diff --git a/src/Requests/ImpervaCustomTaskRequest.php b/src/Requests/ImpervaCustomTaskRequest.php new file mode 100644 index 0000000..f149b2b --- /dev/null +++ b/src/Requests/ImpervaCustomTaskRequest.php @@ -0,0 +1,57 @@ +websiteUrl); + self::requireNonEmpty('metadata.incapsulaScriptUrl', $this->incapsulaScriptUrl); + self::requireNonEmpty('metadata.incapsulaCookies', $this->incapsulaCookies); + } + + protected function fields(): array + { + return [ + 'websiteURL' => $this->websiteUrl, + 'metadata' => self::withoutNulls([ + 'incapsulaScriptUrl' => $this->incapsulaScriptUrl, + 'incapsulaCookies' => $this->incapsulaCookies, + 'reese84UrlEndpoint' => $this->reese84UrlEndpoint, + ]), + 'userAgent' => $this->userAgent, + ] + self::proxyFields($this->proxy); + } +} diff --git a/src/Requests/MtCaptchaRequest.php b/src/Requests/MtCaptchaRequest.php new file mode 100644 index 0000000..35f4e9c --- /dev/null +++ b/src/Requests/MtCaptchaRequest.php @@ -0,0 +1,51 @@ +websiteUrl); + self::requireNonEmpty('websiteKey', $this->websiteKey); + } + + protected function fields(): array + { + return [ + 'websiteURL' => $this->websiteUrl, + 'websiteKey' => $this->websiteKey, + 'pageAction' => $this->pageAction, + 'isInvisible' => $this->isInvisible, + 'userAgent' => $this->userAgent, + ] + self::proxyFields($this->proxy); + } +} diff --git a/src/Requests/ProsopoTaskRequest.php b/src/Requests/ProsopoTaskRequest.php new file mode 100644 index 0000000..4a6bcd4 --- /dev/null +++ b/src/Requests/ProsopoTaskRequest.php @@ -0,0 +1,45 @@ +websiteUrl); + self::requireNonEmpty('websiteKey', $this->websiteKey); + } + + protected function fields(): array + { + return [ + 'websiteURL' => $this->websiteUrl, + 'websiteKey' => $this->websiteKey, + 'userAgent' => $this->userAgent, + ] + self::proxyFields($this->proxy); + } +} diff --git a/src/Requests/RecaptchaComplexImageTaskRequest.php b/src/Requests/RecaptchaComplexImageTaskRequest.php new file mode 100644 index 0000000..f1bf492 --- /dev/null +++ b/src/Requests/RecaptchaComplexImageTaskRequest.php @@ -0,0 +1,69 @@ +/reload` + * or `/userverify` response (`metadata.TaskDefinition`). + * @param list|null $imageUrls Image link (array of one); required if `imagesBase64` is not set. + * @param list|null $imagesBase64 Base64 image without the `data:` prefix (array of one). + * @param string|null $userAgent User-Agent used to load images passed as URLs. + */ + public function __construct( + public readonly RecaptchaGrid $grid, + public readonly ?string $task = null, + public readonly ?string $taskDefinition = null, + public readonly ?array $imageUrls = null, + public readonly ?array $imagesBase64 = null, + public readonly ?string $userAgent = null, + public readonly ?string $websiteUrl = null, + ) { + } + + public function timeouts(): GetResultTimeouts + { + return ComplexImage::timeouts(); + } + + protected function validate(): void + { + if (($this->task ?? '') === '' && ($this->taskDefinition ?? '') === '') { + throw new ValidationException('metadata: either task or taskDefinition must be set'); + } + ComplexImage::requireImages($this->imageUrls, $this->imagesBase64); + ComplexImage::checkImageList('imageUrls', $this->imageUrls, 1); + ComplexImage::checkImageList('imagesBase64', $this->imagesBase64, 1); + } + + protected function fields(): array + { + return [ + 'imageUrls' => $this->imageUrls, + 'imagesBase64' => $this->imagesBase64, + 'metadata' => self::withoutNulls([ + 'Grid' => $this->grid->value, + 'Task' => $this->task, + 'TaskDefinition' => $this->taskDefinition, + ]), + 'userAgent' => $this->userAgent, + 'websiteURL' => $this->websiteUrl, + ]; + } +} diff --git a/src/Requests/RecaptchaGrid.php b/src/Requests/RecaptchaGrid.php new file mode 100644 index 0000000..5e06b91 --- /dev/null +++ b/src/Requests/RecaptchaGrid.php @@ -0,0 +1,13 @@ +|null $enterprisePayload Additional Enterprise parameters, e.g. `['s' => '...']` + * (the `s` value passed to `grecaptcha.enterprise.render`). + * @param string|null $apiDomain Domain to load reCAPTCHA Enterprise from: `www.google.com` or `www.recaptcha.net`. + * @param string|null $pageAction Value of the `action` parameter, if it differs from the default `verify`. + * @param string|null $cookies Cookies as `"name1=value1; name2=value2"`. + */ + public function __construct( + public readonly string $websiteUrl, + public readonly string $websiteKey, + public readonly ?ProxyInfo $proxy = null, + public readonly ?array $enterprisePayload = null, + public readonly ?string $apiDomain = null, + public readonly ?string $pageAction = null, + public readonly ?string $userAgent = null, + public readonly ?string $cookies = null, + ) { + } + + public function timeouts(): GetResultTimeouts + { + return new GetResultTimeouts(1, 3, 180); + } + + protected function validate(): void + { + self::requireNonEmpty('websiteURL', $this->websiteUrl); + self::requireNonEmpty('websiteKey', $this->websiteKey); + if ($this->enterprisePayload === []) { + throw new ValidationException('enterprisePayload must not be an empty array'); + } + } + + protected function fields(): array + { + return [ + 'websiteURL' => $this->websiteUrl, + 'websiteKey' => $this->websiteKey, + 'enterprisePayload' => $this->enterprisePayload, + 'apiDomain' => $this->apiDomain, + 'pageAction' => $this->pageAction, + 'userAgent' => $this->userAgent, + 'cookies' => $this->cookies, + ] + self::proxyFields($this->proxy); + } +} diff --git a/src/Requests/RecaptchaV2Request.php b/src/Requests/RecaptchaV2Request.php new file mode 100644 index 0000000..7d87f2a --- /dev/null +++ b/src/Requests/RecaptchaV2Request.php @@ -0,0 +1,55 @@ +websiteUrl); + self::requireNonEmpty('websiteKey', $this->websiteKey); + } + + protected function fields(): array + { + return [ + 'websiteURL' => $this->websiteUrl, + 'websiteKey' => $this->websiteKey, + 'recaptchaDataSValue' => $this->dataSValue, + 'userAgent' => $this->userAgent, + 'cookies' => $this->cookies, + 'isInvisible' => $this->isInvisible, + ] + self::proxyFields($this->proxy); + } +} diff --git a/src/Requests/RecaptchaV3EnterpriseRequest.php b/src/Requests/RecaptchaV3EnterpriseRequest.php new file mode 100644 index 0000000..77adfc1 --- /dev/null +++ b/src/Requests/RecaptchaV3EnterpriseRequest.php @@ -0,0 +1,48 @@ +websiteUrl); + self::requireNonEmpty('websiteKey', $this->websiteKey); + RecaptchaV3ProxylessRequest::checkMinScore($this->minScore); + } + + protected function fields(): array + { + return [ + 'websiteURL' => $this->websiteUrl, + 'websiteKey' => $this->websiteKey, + 'minScore' => $this->minScore, + 'pageAction' => $this->pageAction, + ]; + } +} diff --git a/src/Requests/RecaptchaV3ProxylessRequest.php b/src/Requests/RecaptchaV3ProxylessRequest.php new file mode 100644 index 0000000..b78fc2c --- /dev/null +++ b/src/Requests/RecaptchaV3ProxylessRequest.php @@ -0,0 +1,60 @@ +websiteUrl); + self::requireNonEmpty('websiteKey', $this->websiteKey); + self::checkMinScore($this->minScore); + } + + protected function fields(): array + { + return [ + 'websiteURL' => $this->websiteUrl, + 'websiteKey' => $this->websiteKey, + 'minScore' => $this->minScore, + 'pageAction' => $this->pageAction, + 'isEnterprise' => $this->isEnterprise, + ]; + } + + /** @internal shared with RecaptchaV3EnterpriseRequest */ + public static function checkMinScore(?float $minScore): void + { + if ($minScore !== null && ($minScore < 0.1 || $minScore > 0.9)) { + throw new ValidationException("minScore must be between 0.1 and 0.9, got {$minScore}"); + } + } +} diff --git a/src/Requests/RecognitionComplexImageTaskRequest.php b/src/Requests/RecognitionComplexImageTaskRequest.php new file mode 100644 index 0000000..6924d4f --- /dev/null +++ b/src/Requests/RecognitionComplexImageTaskRequest.php @@ -0,0 +1,64 @@ + $imagesBase64 Base64 images without a `data:...;base64,` prefix; at least one. + * @param string|null $taskArgument Module argument, e.g. the number to find for `bls_3x3` (`"123"`) or the + * instruction text for `portugal_text_find_icon`. + * @param string|null $payloadType {@see PAYLOAD_AUDIO} for audio modules. + */ + public function __construct( + public readonly array $imagesBase64, + public readonly string $task, + public readonly ?string $taskArgument = null, + public readonly ?string $payloadType = null, + public readonly ?string $userAgent = null, + ) { + } + + public function timeouts(): GetResultTimeouts + { + return ComplexImage::timeouts(); + } + + protected function validate(): void + { + self::requireNonEmpty('metadata.Task', $this->task); + ComplexImage::checkImageList('imagesBase64', $this->imagesBase64, PHP_INT_MAX); + } + + protected function fields(): array + { + return [ + 'imagesBase64' => $this->imagesBase64, + 'metadata' => self::withoutNulls([ + 'Task' => $this->task, + 'TaskArgument' => $this->taskArgument, + 'PayloadType' => $this->payloadType, + ]), + 'userAgent' => $this->userAgent, + ]; + } +} diff --git a/src/Requests/TenDiCustomTaskRequest.php b/src/Requests/TenDiCustomTaskRequest.php new file mode 100644 index 0000000..88efb45 --- /dev/null +++ b/src/Requests/TenDiCustomTaskRequest.php @@ -0,0 +1,55 @@ +websiteUrl); + self::requireNonEmpty('websiteKey', $this->websiteKey); + } + + protected function fields(): array + { + $metadata = self::withoutNulls(['captchaUrl' => $this->captchaUrl, 'aidEncrypted' => $this->aidEncrypted]); + + return [ + 'websiteURL' => $this->websiteUrl, + 'websiteKey' => $this->websiteKey, + 'metadata' => $metadata === [] ? null : $metadata, + 'userAgent' => $this->userAgent, + ] + self::proxyFields($this->proxy); + } +} diff --git a/src/Requests/TextModule.php b/src/Requests/TextModule.php new file mode 100644 index 0000000..11afa75 --- /dev/null +++ b/src/Requests/TextModule.php @@ -0,0 +1,48 @@ +websiteUrl); + self::requireNonEmpty('metadata.tspdCookie', $this->tspdCookie); + self::requireNonEmpty('metadata.htmlPageBase64', $this->htmlPageBase64); + if ($this->proxy->login !== null && strpbrk($this->proxy->login, ';|)') !== false) { + throw new ValidationException("proxy login must not contain the characters ';', '|', ')' for TSPD"); + } + } + + protected function fields(): array + { + return [ + 'websiteURL' => $this->websiteUrl, + 'metadata' => ['tspdCookie' => $this->tspdCookie, 'htmlPageBase64' => $this->htmlPageBase64], + 'userAgent' => $this->userAgent, + ] + self::proxyFields($this->proxy); + } +} diff --git a/src/Requests/TurnstileRequest.php b/src/Requests/TurnstileRequest.php new file mode 100644 index 0000000..259ac43 --- /dev/null +++ b/src/Requests/TurnstileRequest.php @@ -0,0 +1,145 @@ + ...]`. + * + * @param string|null $pageAction The `action` found in the callback used when the captcha is loaded. + * @param string|null $data The `cData` value of the widget. + */ + public static function standard( + string $websiteUrl, + string $websiteKey, + ?ProxyInfo $proxy = null, + ?string $userAgent = null, + ?string $pageAction = null, + ?string $data = null, + ): self { + return new self($websiteUrl, $websiteKey, null, $proxy, $userAgent, $pageAction, $data); + } + + /** + * Cloudflare Challenge solved to a token (`cloudflareTaskType = token`). The values are the parameters passed + * to `window.turnstile.render(el, paramsObj)`. + * + * @param string $pageAction The `action` of the callback; usually `managed` or `non-interactive`. + * @param string $data The `cData` parameter. + * @param string $pageData The `chlPageData` parameter. + * @param string|null $apiJsUrl Link to the captcha script. + */ + public static function challengeToken( + string $websiteUrl, + string $websiteKey, + string $pageAction, + string $userAgent, + string $data, + string $pageData, + ?ProxyInfo $proxy = null, + ?string $apiJsUrl = null, + ): self { + return new self($websiteUrl, $websiteKey, 'token', $proxy, $userAgent, $pageAction, $data, $pageData, $apiJsUrl); + } + + /** + * Cloudflare Challenge solved to the `cf_clearance` cookie. Your own proxy is mandatory. + * + * @param string $htmlPageBase64 Base64 of the "Just a moment" HTML page returned with status 403. + */ + public static function challengeCookie( + string $websiteUrl, + string $websiteKey, + string $htmlPageBase64, + string $userAgent, + ProxyInfo $proxy, + ): self { + return new self($websiteUrl, $websiteKey, 'cf_clearance', $proxy, $userAgent, htmlPageBase64: $htmlPageBase64); + } + + /** + * Cloudflare Waiting Room; the solution is cookies. Your own proxy is mandatory. + * + * @param string $htmlPageBase64 Base64 of the page containing `Waiting Room powered by Cloudflare`. + */ + public static function waitRoom( + string $websiteUrl, + string $websiteKey, + string $htmlPageBase64, + string $userAgent, + ProxyInfo $proxy, + ): self { + return new self($websiteUrl, $websiteKey, 'wait_room', $proxy, $userAgent, htmlPageBase64: $htmlPageBase64); + } + + public function timeouts(): GetResultTimeouts + { + return new GetResultTimeouts(1, 1, 80); + } + + protected function validate(): void + { + self::requireNonEmpty('websiteURL', $this->websiteUrl); + self::requireNonEmpty('websiteKey', $this->websiteKey); + switch ($this->cloudflareTaskType) { + case 'token': + self::requireNonEmpty('pageAction', $this->pageAction); + self::requireNonEmpty('userAgent', $this->userAgent); + self::requireNonEmpty('data', $this->data); + self::requireNonEmpty('pageData', $this->pageData); + break; + case 'cf_clearance': + case 'wait_room': + self::requireNonEmpty('htmlPageBase64', $this->htmlPageBase64); + self::requireNonEmpty('userAgent', $this->userAgent); + break; + } + } + + protected function fields(): array + { + return [ + 'websiteURL' => $this->websiteUrl, + 'websiteKey' => $this->websiteKey, + 'cloudflareTaskType' => $this->cloudflareTaskType, + 'pageAction' => $this->pageAction, + 'userAgent' => $this->userAgent, + 'data' => $this->data, + 'pageData' => $this->pageData, + 'apiJsUrl' => $this->apiJsUrl, + 'htmlPageBase64' => $this->htmlPageBase64, + ] + self::proxyFields($this->proxy); + } +} diff --git a/src/Requests/YidunRequest.php b/src/Requests/YidunRequest.php new file mode 100644 index 0000000..eee749a --- /dev/null +++ b/src/Requests/YidunRequest.php @@ -0,0 +1,62 @@ +websiteUrl); + self::requireNonEmpty('websiteKey', $this->websiteKey); + } + + protected function fields(): array + { + return [ + 'websiteURL' => $this->websiteUrl, + 'websiteKey' => $this->websiteKey, + 'userAgent' => $this->userAgent, + 'yidunGetLib' => $this->yidunGetLib, + 'yidunApiServerSubdomain' => $this->yidunApiServerSubdomain, + 'challenge' => $this->challenge, + 'hcg' => $this->hcg, + 'hct' => $this->hct, + ] + self::proxyFields($this->proxy); + } +} diff --git a/src/TaskResult.php b/src/TaskResult.php new file mode 100644 index 0000000..725df1b --- /dev/null +++ b/src/TaskResult.php @@ -0,0 +1,29 @@ +|null $solution The solution object (shape depends on the captcha type); null while processing. + */ + private function __construct( + public readonly bool $isReady, + public readonly ?array $solution, + ) { + } + + public static function processing(): self + { + return new self(false, null); + } + + /** @param array $solution */ + public static function ready(array $solution): self + { + return new self(true, $solution); + } +} diff --git a/tests/ClientTest.php b/tests/ClientTest.php new file mode 100644 index 0000000..bd4e999 --- /dev/null +++ b/tests/ClientTest.php @@ -0,0 +1,194 @@ +api = new FakeTransport(); + } + + /** 10 ms schedule instead of the real 1 s / 3 s / 180 s one. */ + private function client(float $timeout = 0.3): CapMonsterClient + { + return new CapMonsterClient( + new ClientOptions('KEY', serviceUrl: 'https://api.test/', timeoutsOverride: new GetResultTimeouts(0.01, 0.01, $timeout)), + $this->api, + ); + } + + private static function request(): RecaptchaV2Request + { + return new RecaptchaV2Request('https://example.com', 'SITEKEY'); + } + + public function testSolveCreatesTaskAndPollsUntilReady(): void + { + $this->api + ->onJson('createTask', ['errorId' => 0, 'taskId' => 42]) + ->onJson('getTaskResult', ['errorId' => 0, 'status' => 'processing']) + ->onJson('getTaskResult', ['errorId' => 0, 'status' => 'ready', 'solution' => ['gRecaptchaResponse' => 'TOKEN']]); + + $solution = $this->client()->solve(self::request()); + + self::assertSame(['gRecaptchaResponse' => 'TOKEN'], $solution); + self::assertSame('https://api.test/createTask', $this->api->requests[0]['url']); + self::assertSame([ + 'clientKey' => 'KEY', + 'task' => ['type' => 'RecaptchaV2Task', 'websiteURL' => 'https://example.com', 'websiteKey' => 'SITEKEY'], + 'softId' => ClientOptions::DEFAULT_SOFT_ID, + ], $this->api->bodies('createTask')[0]); + self::assertSame([['clientKey' => 'KEY', 'taskId' => 42], ['clientKey' => 'KEY', 'taskId' => 42]], $this->api->bodies('getTaskResult')); + } + + public function testCreateTaskApiErrorIsReported(): void + { + $this->api->onJson('createTask', ['errorId' => 1, 'errorCode' => 'ERROR_KEY_DOES_NOT_EXIST', 'errorDescription' => 'bad key']); + + try { + $this->client()->solve(self::request()); + self::fail('no exception'); + } catch (CreateTaskException $e) { + self::assertSame('ERROR_KEY_DOES_NOT_EXIST', $e->errorCode); + self::assertSame('bad key', $e->errorDescription); + self::assertStringContainsString('ERROR_KEY_DOES_NOT_EXIST', $e->getMessage()); + } + } + + public function testErrorJsonWithHttpErrorStatusIsAnApiError(): void + { + $this->api->onJson('createTask', ['errorId' => 1, 'errorCode' => 'ERROR_ZERO_BALANCE', 'errorDescription' => 'no money'], 400); + + $this->expectException(CreateTaskException::class); + $this->expectExceptionMessage('ERROR_ZERO_BALANCE'); + $this->client()->createTask(self::request()); + } + + public function testGetResultApiErrorIsReported(): void + { + $this->api + ->onJson('createTask', ['errorId' => 0, 'taskId' => 1]) + ->onJson('getTaskResult', ['errorId' => 12, 'errorCode' => 'ERROR_CAPTCHA_UNSOLVABLE', 'errorDescription' => 'nope']); + + $this->expectException(GetResultException::class); + $this->expectExceptionMessage('ERROR_CAPTCHA_UNSOLVABLE'); + $this->client()->solve(self::request()); + } + + public function testHttp500And503WhilePollingMeanStillProcessing(): void + { + $this->api + ->on('getTaskResult', new HttpResponse(500, 'Internal Server Error')) + ->on('getTaskResult', new HttpResponse(503, '')); + + self::assertFalse($this->client()->getTaskResult(7)->isReady); + self::assertFalse($this->client()->getTaskResult(7)->isReady); + } + + public function testOtherHttpStatusIsAnError(): void + { + $this->api->on('createTask', new HttpResponse(502, 'bad gateway')); + + try { + $this->client()->createTask(self::request()); + self::fail('no exception'); + } catch (UnexpectedHttpStatusException $e) { + self::assertSame(502, $e->status); + self::assertSame('createTask', $e->method); + } + } + + public function testPollingGivesUpWithTimeout(): void + { + $this->api + ->onJson('createTask', ['errorId' => 0, 'taskId' => 5]) + ->onJson('getTaskResult', ['errorId' => 0, 'status' => 'processing']); + + $started = microtime(true); + try { + $this->client(0.1)->solve(self::request()); + self::fail('no exception'); + } catch (TimeoutException $e) { + self::assertSame(5, $e->taskId); + } + self::assertLessThan(1.0, microtime(true) - $started); + } + + public function testInvalidRequestIsRejectedBeforeAnyHttpCall(): void + { + try { + $this->client()->solve(new RecaptchaV2Request('', 'K')); + self::fail('no exception'); + } catch (ValidationException) { + } + self::assertSame([], $this->api->requests); + } + + public function testGetBalance(): void + { + $this->api->onJson('getBalance', ['errorId' => 0, 'balance' => 12.5]); + + self::assertSame(12.5, $this->client()->getBalance()); + self::assertSame([['clientKey' => 'KEY']], $this->api->bodies('getBalance')); + } + + public function testGetBalanceError(): void + { + $this->api->onJson('getBalance', ['errorId' => 1, 'errorCode' => 'ERROR_KEY_DOES_NOT_EXIST', 'errorDescription' => 'bad key']); + + $this->expectException(GetBalanceException::class); + $this->client()->getBalance(); + } + + public function testMalformedResponse(): void + { + $this->api->on('getBalance', new HttpResponse(200, 'not json')); + + $this->expectException(MalformedResponseException::class); + $this->client()->getBalance(); + } + + public function testTransportErrorsPropagateAndShareTheBaseClass(): void + { + $this->api->on('getBalance', new TransportException('curl error 28: timeout')); + + $this->expectException(CapMonsterException::class); + $this->expectExceptionMessage('curl error 28'); + $this->client()->getBalance(); + } + + public function testDefaultScheduleComesFromTheRequest(): void + { + $this->api + ->onJson('createTask', ['errorId' => 0, 'taskId' => 1]) + ->onJson('getTaskResult', ['errorId' => 0, 'status' => 'ready', 'solution' => ['text' => 'abc']]); + $client = new CapMonsterClient(new ClientOptions('KEY'), $this->api); + + // ImageToText: first poll after 0.35 s + $started = microtime(true); + self::assertSame(['text' => 'abc'], $client->solve(new \Zennolab\CapMonsterCloud\Requests\ImageToTextRequest('QUJD'))); + self::assertGreaterThanOrEqual(0.3, microtime(true) - $started); + self::assertSame('https://api.capmonster.cloud/createTask', $this->api->requests[0]['url']); + } +} diff --git a/tests/FakeTransport.php b/tests/FakeTransport.php new file mode 100644 index 0000000..c067ce7 --- /dev/null +++ b/tests/FakeTransport.php @@ -0,0 +1,57 @@ + */ + public array $requests = []; + + /** @var array> */ + private array $queues = []; + + /** Queues a response for a method; the last one of a method is repeated forever. */ + public function on(string $method, HttpResponse|TransportException $response): self + { + $this->queues[$method][] = $response; + + return $this; + } + + public function onJson(string $method, array $json, int $status = 200): self + { + return $this->on($method, new HttpResponse($status, json_encode($json, JSON_THROW_ON_ERROR))); + } + + public function post(string $url, array $body): HttpResponse + { + $this->requests[] = ['url' => $url, 'body' => $body]; + $method = substr($url, strrpos($url, '/') + 1); + $queue = &$this->queues[$method]; + if (empty($queue)) { + throw new \LogicException("no fake response for {$method}"); + } + $response = count($queue) > 1 ? array_shift($queue) : $queue[0]; + if ($response instanceof TransportException) { + throw $response; + } + + return $response; + } + + /** @return list bodies sent to one method */ + public function bodies(string $method): array + { + return array_values(array_map( + static fn ($r) => $r['body'], + array_filter($this->requests, static fn ($r) => str_ends_with($r['url'], '/' . $method)), + )); + } +} diff --git a/tests/RequestsTest.php b/tests/RequestsTest.php new file mode 100644 index 0000000..1d98465 --- /dev/null +++ b/tests/RequestsTest.php @@ -0,0 +1,284 @@ + 'http', 'proxyAddress' => '1.2.3.4', 'proxyPort' => 8080, 'proxyLogin' => 'u', 'proxyPassword' => 'p', + ]; + + /** @return iterable */ + public static function tasks(): iterable + { + $u = self::URL; + $p = self::PROXY_JSON; + + yield 'recaptcha v2 minimal' => [ + new Requests\RecaptchaV2Request($u, 'KEY'), + ['type' => 'RecaptchaV2Task', 'websiteURL' => $u, 'websiteKey' => 'KEY'], + ]; + yield 'recaptcha v2 full' => [ + new Requests\RecaptchaV2Request($u, 'KEY', self::proxy(), 'DS', 'UA', 'a=b', true), + ['type' => 'RecaptchaV2Task', 'websiteURL' => $u, 'websiteKey' => 'KEY', 'recaptchaDataSValue' => 'DS', + 'userAgent' => 'UA', 'cookies' => 'a=b', 'isInvisible' => true] + $p, + ]; + yield 'recaptcha v2 enterprise' => [ + new Requests\RecaptchaV2EnterpriseRequest($u, 'KEY', enterprisePayload: ['s' => 'S'], apiDomain: 'www.recaptcha.net', pageAction: 'login'), + ['type' => 'RecaptchaV2EnterpriseTask', 'websiteURL' => $u, 'websiteKey' => 'KEY', + 'enterprisePayload' => ['s' => 'S'], 'apiDomain' => 'www.recaptcha.net', 'pageAction' => 'login'], + ]; + yield 'recaptcha v3' => [ + new Requests\RecaptchaV3ProxylessRequest($u, 'KEY', minScore: 0.3, pageAction: 'verify', isEnterprise: false), + ['type' => 'RecaptchaV3TaskProxyless', 'websiteURL' => $u, 'websiteKey' => 'KEY', 'minScore' => 0.3, + 'pageAction' => 'verify', 'isEnterprise' => false], + ]; + yield 'recaptcha v3 enterprise' => [ + new Requests\RecaptchaV3EnterpriseRequest($u, 'KEY', 0.9), + ['type' => 'RecaptchaV3EnterpriseTask', 'websiteURL' => $u, 'websiteKey' => 'KEY', 'minScore' => 0.9], + ]; + yield 'recaptcha click by url' => [ + new Requests\RecaptchaComplexImageTaskRequest(RecaptchaGrid::Grid3x3, task: 'Click on traffic lights', imageUrls: ['https://x/img.jpg']), + ['type' => 'ComplexImageTask', 'class' => 'recaptcha', 'imageUrls' => ['https://x/img.jpg'], + 'metadata' => ['Grid' => '3x3', 'Task' => 'Click on traffic lights']], + ]; + yield 'recaptcha click by base64' => [ + new Requests\RecaptchaComplexImageTaskRequest(RecaptchaGrid::Grid1x1, taskDefinition: '/m/015qff', imagesBase64: ['QUJD'], userAgent: 'UA', websiteUrl: $u), + ['type' => 'ComplexImageTask', 'class' => 'recaptcha', 'imagesBase64' => ['QUJD'], + 'metadata' => ['Grid' => '1x1', 'TaskDefinition' => '/m/015qff'], 'userAgent' => 'UA', 'websiteURL' => $u], + ]; + yield 'recognition' => [ + new Requests\RecognitionComplexImageTaskRequest(['QUJD'], 'bills_audio', '123', Requests\RecognitionComplexImageTaskRequest::PAYLOAD_AUDIO), + ['type' => 'ComplexImageTask', 'class' => 'recognition', 'imagesBase64' => ['QUJD'], + 'metadata' => ['Task' => 'bills_audio', 'TaskArgument' => '123', 'PayloadType' => 'Audio']], + ]; + yield 'image to text' => [ + new Requests\ImageToTextRequest('QUJD', TextModule::UNIVERSAL, 70, true, true, false), + ['type' => 'ImageToTextTask', 'body' => 'QUJD', 'capMonsterModule' => 'universal', 'recognizingThreshold' => 70, + 'case' => true, 'numeric' => 1, 'math' => false], + ]; + yield 'funcaptcha' => [ + new Requests\FuncaptchaRequest($u, 'PK', self::proxy(), funcaptchaApiJsSubdomain: 'x.arkoselabs.com', data: '{"blob":"b"}'), + ['type' => 'FunCaptchaTask', 'websiteURL' => $u, 'websitePublicKey' => 'PK', + 'funcaptchaApiJSSubdomain' => 'x.arkoselabs.com', 'data' => '{"blob":"b"}'] + $p, + ]; + yield 'geetest v3' => [ + Requests\GeetestRequest::v3($u, 'GT', 'CH', geetestGetLib: '{"static_servers":["x"]}'), + ['type' => 'GeeTestTask', 'websiteURL' => $u, 'gt' => 'GT', 'version' => 3, 'challenge' => 'CH', + 'geetestGetLib' => '{"static_servers":["x"]}'], + ]; + yield 'geetest v4' => [ + Requests\GeetestRequest::v4($u, 'CID', ['riskType' => 'slide'], self::proxy(), userAgent: 'UA'), + ['type' => 'GeeTestTask', 'websiteURL' => $u, 'gt' => 'CID', 'version' => 4, + 'initParameters' => ['riskType' => 'slide'], 'userAgent' => 'UA'] + $p, + ]; + yield 'geetest v4 without init parameters' => [ + Requests\GeetestRequest::v4($u, 'CID', []), + ['type' => 'GeeTestTask', 'websiteURL' => $u, 'gt' => 'CID', 'version' => 4], + ]; + yield 'turnstile standard' => [ + Requests\TurnstileRequest::standard($u, '0xKEY', pageAction: 'login', data: 'cdata'), + ['type' => 'TurnstileTask', 'websiteURL' => $u, 'websiteKey' => '0xKEY', 'pageAction' => 'login', 'data' => 'cdata'], + ]; + yield 'turnstile challenge token' => [ + Requests\TurnstileRequest::challengeToken($u, '0xKEY', 'managed', 'UA', 'cdata', 'pdata', apiJsUrl: 'https://x/api.js'), + ['type' => 'TurnstileTask', 'websiteURL' => $u, 'websiteKey' => '0xKEY', 'cloudflareTaskType' => 'token', + 'pageAction' => 'managed', 'userAgent' => 'UA', 'data' => 'cdata', 'pageData' => 'pdata', 'apiJsUrl' => 'https://x/api.js'], + ]; + yield 'turnstile cf_clearance' => [ + Requests\TurnstileRequest::challengeCookie($u, 'xxxxxx', 'SFRNTA==', 'UA', self::proxy()), + ['type' => 'TurnstileTask', 'websiteURL' => $u, 'websiteKey' => 'xxxxxx', 'cloudflareTaskType' => 'cf_clearance', + 'userAgent' => 'UA', 'htmlPageBase64' => 'SFRNTA=='] + $p, + ]; + yield 'turnstile wait room' => [ + Requests\TurnstileRequest::waitRoom($u, 'xxxxxx', 'SFRNTA==', 'UA', self::proxy()), + ['type' => 'TurnstileTask', 'websiteURL' => $u, 'websiteKey' => 'xxxxxx', 'cloudflareTaskType' => 'wait_room', + 'userAgent' => 'UA', 'htmlPageBase64' => 'SFRNTA=='] + $p, + ]; + yield 'amazon visible' => [ + Requests\AmazonWafRequest::visibleCaptcha($u, 'KEY', 'https://x/jsapi.js', self::proxy(), 'UA', true), + ['type' => 'AmazonTask', 'websiteURL' => $u, 'websiteKey' => 'KEY', 'captchaScript' => 'https://x/jsapi.js', + 'userAgent' => 'UA', 'cookieSolution' => true] + $p, + ]; + yield 'amazon challenge and captcha' => [ + Requests\AmazonWafRequest::challengeAndCaptcha($u, 'https://x/challenge.js', 'K', 'C', 'IV', 'https://x/captcha.js'), + ['type' => 'AmazonTask', 'websiteURL' => $u, 'websiteKey' => 'K', 'captchaScript' => 'https://x/captcha.js', + 'challengeScript' => 'https://x/challenge.js', 'context' => 'C', 'iv' => 'IV'], + ]; + yield 'amazon challenge only sends empty context and iv' => [ + Requests\AmazonWafRequest::challengeOnly($u, 'https://x/challenge.js', cookieSolution: true), + ['type' => 'AmazonTask', 'websiteURL' => $u, 'challengeScript' => 'https://x/challenge.js', 'context' => '', 'iv' => '', + 'cookieSolution' => true], + ]; + yield 'binance' => [ + new Requests\BinanceTaskRequest($u, 'login', 'vid', userAgent: 'UA'), + ['type' => 'BinanceTask', 'websiteURL' => $u, 'websiteKey' => 'login', 'validateId' => 'vid', 'userAgent' => 'UA'], + ]; + yield 'mtcaptcha' => [ + new Requests\MtCaptchaRequest($u, 'MTPublic-x', pageAction: 'login', isInvisible: true), + ['type' => 'MTCaptchaTask', 'websiteURL' => $u, 'websiteKey' => 'MTPublic-x', 'pageAction' => 'login', 'isInvisible' => true], + ]; + yield 'yidun enterprise' => [ + new Requests\YidunRequest($u, 'SK', yidunGetLib: 'https://x/load.js', yidunApiServerSubdomain: 'c.example.com', challenge: 'CH', hcg: 'H', hct: 1700000000000), + ['type' => 'YidunTask', 'websiteURL' => $u, 'websiteKey' => 'SK', 'yidunGetLib' => 'https://x/load.js', + 'yidunApiServerSubdomain' => 'c.example.com', 'challenge' => 'CH', 'hcg' => 'H', 'hct' => 1700000000000], + ]; + yield 'prosopo' => [ + new Requests\ProsopoTaskRequest($u, 'SK'), + ['type' => 'ProsopoTask', 'websiteURL' => $u, 'websiteKey' => 'SK'], + ]; + yield 'datadome' => [ + new Requests\DataDomeCustomTaskRequest($u, 'https://geo.captcha-delivery.com/captcha/?t=fe', 'datadome=abc', self::proxy(), + Requests\DataDomeCustomTaskRequest::VERSION_NEW, 'UA'), + ['type' => 'CustomTask', 'class' => 'DataDome', 'websiteURL' => $u, + 'metadata' => ['captchaUrl' => 'https://geo.captcha-delivery.com/captcha/?t=fe', 'datadomeCookie' => 'datadome=abc', 'datadomeVersion' => 'new'], + 'userAgent' => 'UA'] + $p, + ]; + yield 'imperva' => [ + new Requests\ImpervaCustomTaskRequest($u, '_Incapsula_Resource?x=1', 'incap_ses=1', self::proxy(), '/endpoint?d=site.com'), + ['type' => 'CustomTask', 'class' => 'Imperva', 'websiteURL' => $u, + 'metadata' => ['incapsulaScriptUrl' => '_Incapsula_Resource?x=1', 'incapsulaCookies' => 'incap_ses=1', 'reese84UrlEndpoint' => '/endpoint?d=site.com']] + $p, + ]; + yield 'tspd' => [ + new Requests\TspdCustomTaskRequest($u, 'TS1=2;', 'SFRNTA==', self::proxy()), + ['type' => 'CustomTask', 'class' => 'tspd', 'websiteURL' => $u, 'metadata' => ['tspdCookie' => 'TS1=2;', 'htmlPageBase64' => 'SFRNTA==']] + $p, + ]; + yield 'hunt fingerprint' => [ + Requests\HuntCustomTaskRequest::fingerprint($u, 'https://x/api.js', self::proxy()), + ['type' => 'CustomTask', 'class' => 'HUNT', 'websiteURL' => $u, 'metadata' => ['apiGetLib' => 'https://x/api.js']] + $p, + ]; + yield 'hunt token' => [ + Requests\HuntCustomTaskRequest::byToken($u, 'https://x/api.js', 'TOKEN', self::proxy(), 'UA'), + ['type' => 'CustomTask', 'class' => 'HUNT', 'websiteURL' => $u, 'metadata' => ['apiGetLib' => 'https://x/api.js', 'data' => 'TOKEN'], + 'userAgent' => 'UA'] + $p, + ]; + yield 'hunt widget url' => [ + Requests\HuntCustomTaskRequest::byWidgetUrl($u, 'https://x/api.js', 'https://c/widget?hash=1', self::proxy()), + ['type' => 'CustomTask', 'class' => 'HUNT', 'websiteURL' => $u, 'metadata' => ['apiGetLib' => 'https://x/api.js', 'widgetUrl' => 'https://c/widget?hash=1']] + $p, + ]; + yield 'basilisk' => [ + new Requests\BasiliskCustomTaskRequest($u, 'KEY', userAgent: 'UA'), + ['type' => 'CustomTask', 'class' => 'Basilisk', 'websiteURL' => $u, 'websiteKey' => 'KEY', 'userAgent' => 'UA'], + ]; + yield 'tendi minimal has no metadata' => [ + new Requests\TenDiCustomTaskRequest($u, '189123456'), + ['type' => 'CustomTask', 'class' => 'TenDI', 'websiteURL' => $u, 'websiteKey' => '189123456'], + ]; + yield 'tendi with metadata' => [ + new Requests\TenDiCustomTaskRequest($u, '189123456', captchaUrl: 'https://x/TCaptcha.js', aidEncrypted: 'AID'), + ['type' => 'CustomTask', 'class' => 'TenDI', 'websiteURL' => $u, 'websiteKey' => '189123456', + 'metadata' => ['captchaUrl' => 'https://x/TCaptcha.js', 'aidEncrypted' => 'AID']], + ]; + yield 'alibaba' => [ + new Requests\AlibabaCustomTaskRequest($u, 'S', 'P', region: 'sgp', userCertifyId: 'CID', cookieRequired: true), + ['type' => 'CustomTask', 'class' => 'alibaba', 'websiteURL' => $u, + 'metadata' => ['sceneId' => 'S', 'prefix' => 'P', 'region' => 'sgp', 'UserCertifyId' => 'CID', 'cookieRequired' => true]], + ]; + yield 'friendly' => [ + new Requests\FriendlyCustomTaskRequest($u, 'FCKEY', 'https://x/site.min.js'), + ['type' => 'CustomTask', 'class' => 'friendly', 'websiteURL' => $u, 'websiteKey' => 'FCKEY', 'metadata' => ['apiGetLib' => 'https://x/site.min.js']], + ]; + yield 'altcha with empty website key' => [ + new Requests\AltchaCustomTaskRequest($u, '', 'c', '1', 's', 'g'), + ['type' => 'CustomTask', 'class' => 'altcha', 'websiteURL' => $u, 'websiteKey' => '', + 'metadata' => ['challenge' => 'c', 'iterations' => '1', 'salt' => 's', 'signature' => 'g']], + ]; + } + + #[DataProvider('tasks')] + public function testTaskJson(CaptchaRequest $request, array $expected): void + { + $task = $request->toTask(); + ksort($task); + ksort($expected); + self::assertSame($expected, $task); + self::assertGreaterThan(0, $request->timeouts()->timeout); + } + + /** @return iterable */ + public static function invalidRequests(): iterable + { + $u = self::URL; + + yield 'empty websiteURL' => [fn () => new Requests\RecaptchaV2Request('', 'KEY'), 'websiteURL']; + yield 'empty websiteKey' => [fn () => Requests\TurnstileRequest::standard($u, ''), 'websiteKey']; + yield 'minScore too high' => [fn () => new Requests\RecaptchaV3ProxylessRequest($u, 'KEY', 1.1), 'minScore']; + yield 'minScore too low (enterprise)' => [fn () => new Requests\RecaptchaV3EnterpriseRequest($u, 'KEY', 0.05), 'minScore']; + yield 'empty enterprisePayload' => [fn () => new Requests\RecaptchaV2EnterpriseRequest($u, 'KEY', enterprisePayload: []), 'enterprisePayload']; + yield 'threshold out of range' => [fn () => new Requests\ImageToTextRequest('QUJD', recognizingThreshold: 101), 'recognizingThreshold']; + yield 'empty image body' => [fn () => new Requests\ImageToTextRequest(''), 'body']; + yield 'data uri body' => [fn () => new Requests\ImageToTextRequest('data:image/png;base64,QUJD'), 'base64']; + yield 'geetest v3 empty challenge' => [fn () => Requests\GeetestRequest::v3($u, 'GT', ''), 'challenge']; + yield 'turnstile token without pageData' => [fn () => Requests\TurnstileRequest::challengeToken($u, 'K', 'managed', 'UA', 'd', ''), 'pageData']; + yield 'turnstile cookie without html' => [fn () => Requests\TurnstileRequest::challengeCookie($u, 'K', '', 'UA', self::proxy()), 'htmlPageBase64']; + yield 'amazon visible without script' => [fn () => Requests\AmazonWafRequest::visibleCaptcha($u, 'K', ''), 'captchaScript']; + yield 'amazon option 2 without iv' => [fn () => Requests\AmazonWafRequest::challengeAndCaptcha($u, 'S', 'K', 'C', ''), 'iv']; + yield 'recaptcha click without task' => [fn () => new Requests\RecaptchaComplexImageTaskRequest(RecaptchaGrid::Grid3x3, imageUrls: ['u']), 'task']; + yield 'recaptcha click without images' => [fn () => new Requests\RecaptchaComplexImageTaskRequest(RecaptchaGrid::Grid3x3, 'T'), 'imageUrls']; + yield 'recaptcha click two images' => [fn () => new Requests\RecaptchaComplexImageTaskRequest(RecaptchaGrid::Grid3x3, 'T', imageUrls: ['a', 'b']), 'at most 1']; + yield 'recognition without images' => [fn () => new Requests\RecognitionComplexImageTaskRequest([], 'shein'), 'imagesBase64']; + yield 'recognition without task' => [fn () => new Requests\RecognitionComplexImageTaskRequest(['QUJD'], ''), 'Task']; + yield 'datadome empty cookie' => [fn () => new Requests\DataDomeCustomTaskRequest($u, 'U', '', self::proxy()), 'datadomeCookie']; + yield 'hunt empty token' => [fn () => Requests\HuntCustomTaskRequest::byToken($u, 'L', '', self::proxy()), 'metadata.data']; + yield 'tspd bad proxy login' => [ + fn () => new Requests\TspdCustomTaskRequest($u, 'C', 'H', new ProxyInfo(ProxyType::Http, 'h', 1, 'a|b', 'p')), + 'proxy login', + ]; + yield 'altcha empty salt' => [fn () => new Requests\AltchaCustomTaskRequest($u, '', 'c', '1', '', 'g'), 'metadata.salt']; + yield 'alibaba empty prefix' => [fn () => new Requests\AlibabaCustomTaskRequest($u, 'S', ''), 'metadata.prefix']; + yield 'binance empty validateId' => [fn () => new Requests\BinanceTaskRequest($u, 'login', ''), 'validateId']; + } + + /** @param \Closure(): CaptchaRequest $make */ + #[DataProvider('invalidRequests')] + public function testValidation(\Closure $make, string $messagePart): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage($messagePart); + $make()->toTask(); + } + + public function testProxyRejectsBadPort(): void + { + $this->expectException(ValidationException::class); + new ProxyInfo(ProxyType::Socks5, 'h', 70000); + } + + public function testImageToTextFromFile(): void + { + $file = tempnam(sys_get_temp_dir(), 'img'); + file_put_contents($file, 'ABC'); + try { + $task = Requests\ImageToTextRequest::fromFile($file, TextModule::YANDEX)->toTask(); + } finally { + unlink($file); + } + self::assertSame(['type' => 'ImageToTextTask', 'body' => 'QUJD', 'capMonsterModule' => 'yandex'], $task); + } + + public function testSecretsAreMaskedInDumps(): void + { + $dump = print_r(self::proxy(), true) . print_r(new \Zennolab\CapMonsterCloud\ClientOptions('SECRETKEY'), true); + self::assertStringNotContainsString('SECRETKEY', $dump); + self::assertStringNotContainsString("[password] => p\n", $dump); + } +} From d3fc56f48111571a91bcfb4f0e1c99d648dbf634 Mon Sep 17 00:00:00 2001 From: "pavel.grinkevich" Date: Tue, 27 Oct 2026 12:30:23 +0000 Subject: [PATCH 2/3] Add an example for every task type One runnable example per request type (mirroring the Python SDK examples), plus balance, proxy_usage and client_proxy. Reads the key from CAPMONSTER_API_KEY. Co-Authored-By: Claude Opus 5.5 --- examples/alibaba.php | 19 +++++++++++++++++++ examples/altcha.php | 21 +++++++++++++++++++++ examples/amazon_waf.php | 17 +++++++++++++++++ examples/balance.php | 11 +++++++++++ examples/basilisk.php | 13 +++++++++++++ examples/binance.php | 17 +++++++++++++++++ examples/client_proxy.php | 18 ++++++++++++++++++ examples/datadome.php | 23 +++++++++++++++++++++++ examples/friendly.php | 18 ++++++++++++++++++ examples/funcaptcha.php | 22 ++++++++++++++++++++++ examples/geetest_v3.php | 17 +++++++++++++++++ examples/geetest_v4.php | 17 +++++++++++++++++ examples/hunt.php | 22 ++++++++++++++++++++++ examples/image_to_text.php | 11 ++++++++--- examples/images/sample.jpg | Bin 0 -> 5087 bytes examples/imperva.php | 24 ++++++++++++++++++++++++ examples/mtcaptcha.php | 13 +++++++++++++ examples/prosopo.php | 13 +++++++++++++ examples/proxy_usage.php | 22 ++++++++++++++++++++++ examples/recaptcha_image.php | 20 ++++++++++++++++++++ examples/recaptcha_v2.php | 18 +++++------------- examples/recaptcha_v2_enterprise.php | 17 +++++++++++++++++ examples/recaptcha_v3.php | 17 +++++++++++++++++ examples/recaptcha_v3_enterprise.php | 18 ++++++++++++++++++ examples/recognition_complex_image.php | 22 ++++++++++++++++++++++ examples/tendi.php | 13 +++++++++++++ examples/tspd.php | 23 +++++++++++++++++++++++ examples/turnstile.php | 13 +++++++++++++ examples/turnstile_cf_clearance.php | 23 +++++++++++++++++++++++ examples/turnstile_waitroom.php | 23 +++++++++++++++++++++++ examples/turnstile_with_proxy.php | 23 ----------------------- examples/yidun.php | 13 +++++++++++++ 32 files changed, 522 insertions(+), 39 deletions(-) create mode 100644 examples/alibaba.php create mode 100644 examples/altcha.php create mode 100644 examples/amazon_waf.php create mode 100644 examples/balance.php create mode 100644 examples/basilisk.php create mode 100644 examples/binance.php create mode 100644 examples/client_proxy.php create mode 100644 examples/datadome.php create mode 100644 examples/friendly.php create mode 100644 examples/funcaptcha.php create mode 100644 examples/geetest_v3.php create mode 100644 examples/geetest_v4.php create mode 100644 examples/hunt.php create mode 100644 examples/images/sample.jpg create mode 100644 examples/imperva.php create mode 100644 examples/mtcaptcha.php create mode 100644 examples/prosopo.php create mode 100644 examples/proxy_usage.php create mode 100644 examples/recaptcha_image.php create mode 100644 examples/recaptcha_v2_enterprise.php create mode 100644 examples/recaptcha_v3.php create mode 100644 examples/recaptcha_v3_enterprise.php create mode 100644 examples/recognition_complex_image.php create mode 100644 examples/tendi.php create mode 100644 examples/tspd.php create mode 100644 examples/turnstile.php create mode 100644 examples/turnstile_cf_clearance.php create mode 100644 examples/turnstile_waitroom.php delete mode 100644 examples/turnstile_with_proxy.php create mode 100644 examples/yidun.php diff --git a/examples/alibaba.php b/examples/alibaba.php new file mode 100644 index 0000000..7cf59d0 --- /dev/null +++ b/examples/alibaba.php @@ -0,0 +1,19 @@ +solve(new AlibabaCustomTaskRequest( + 'https://www.example.com', + sceneId: '1ww7426c', + prefix: 'dlw3kug', + userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36', + apiGetLib: 'https://o.example.com/captcha-frontend/aliyunCaptcha/AliyunCaptcha.js?t=2041', +)); +print_r($solution); diff --git a/examples/altcha.php b/examples/altcha.php new file mode 100644 index 0000000..49da4bd --- /dev/null +++ b/examples/altcha.php @@ -0,0 +1,21 @@ +solve(new AltchaCustomTaskRequest( + 'https://example.com/login', + 'altcha-public-key-123', + challenge: 'challenge_string_here', + iterations: '1000', + salt: 'salt_string_here', + signature: 'signature_string_here', + userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36', +)); +print_r($solution); diff --git a/examples/amazon_waf.php b/examples/amazon_waf.php new file mode 100644 index 0000000..3c10242 --- /dev/null +++ b/examples/amazon_waf.php @@ -0,0 +1,17 @@ +solve(AmazonWafRequest::visibleCaptcha( + 'https://example.com/', + 'AQIDAHjcYu/GjX+QlghicBgQ/7bFaQZ+m5FKCMDnO+vTp9wjBQ2MNq0uv7gT8+IFxrGrLgsA', + 'https://234324vgvc23.yejk.captcha-sdk.awswaf.com/234324vgvc23/jsapi.js', +)); +print_r($solution); diff --git a/examples/balance.php b/examples/balance.php new file mode 100644 index 0000000..82e8aa0 --- /dev/null +++ b/examples/balance.php @@ -0,0 +1,11 @@ +getBalance()); diff --git a/examples/basilisk.php b/examples/basilisk.php new file mode 100644 index 0000000..da4e49c --- /dev/null +++ b/examples/basilisk.php @@ -0,0 +1,13 @@ +solve(new BasiliskCustomTaskRequest('https://example.com/account/register', 'b7890hre5cf2544b2759c19fb2600897')); +print_r($solution); diff --git a/examples/binance.php b/examples/binance.php new file mode 100644 index 0000000..54a7de4 --- /dev/null +++ b/examples/binance.php @@ -0,0 +1,17 @@ +solve(new BinanceTaskRequest( + 'https://accounts.binance.com/ru/login?loginChannel=&return_to=', + 'login', + '2b8137c0b9b44189800368819354e114', +)); +print_r($solution); diff --git a/examples/client_proxy.php b/examples/client_proxy.php new file mode 100644 index 0000000..0c63c90 --- /dev/null +++ b/examples/client_proxy.php @@ -0,0 +1,18 @@ +getBalance()); diff --git a/examples/datadome.php b/examples/datadome.php new file mode 100644 index 0000000..a008bee --- /dev/null +++ b/examples/datadome.php @@ -0,0 +1,23 @@ +solve(new DataDomeCustomTaskRequest( + 'https://antoinevastel.com/bots/datadome', + captchaUrl: 'https://geo.captcha-delivery.com/captcha/?initialCid=AHrlqAAAAAMAJxx4dfgwjzwAQW0ctQ%3D%3D&hash=D66B23AC3F48A302A7654416846381&cid=d3k5rbDsu8cq0kmPHISS3hsC3f4qeL_K12~G33PrE4fbkmDYSul6l0Ze_aG5sUHLKG0676UpTv6GFvUgIActglZF33GTodOoRhEDkMMsuWTodlYa3YYQ9xKy9J89PAWh&t=fe&referer=https%3A%2F%2Fantoinevastel.com%2Fbots%2Fdatadome&s=21705&e=04fc682817ba89bf8fa4b18031fa53294fa0fb7449d95c036a1986413e6dfc7d', + datadomeCookie: 'datadome=d3k5rbDsu8cq0kmPHISS3hsC3f4qeL_K12~G33PrE4fbkmDYSul6l0Ze_aG5sUHLKG0676UpTv6GFvUgIActglZF33GTodOoRhEDkMMsuWTodlYa3YYQ9xKy9J89PAWh', + proxy: $proxy, + userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36', +)); +print_r($solution); diff --git a/examples/friendly.php b/examples/friendly.php new file mode 100644 index 0000000..ab874b8 --- /dev/null +++ b/examples/friendly.php @@ -0,0 +1,18 @@ +solve(new FriendlyCustomTaskRequest( + 'https://www.example.com', + 'FCMSITEKEY000000000000000000000000000000', + apiGetLib: 'https://cdn.jsdelivr.net/npm/friendly-challenge@0.9.18/widget.module.min.js', + userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36', +)); +print_r($solution); diff --git a/examples/funcaptcha.php b/examples/funcaptcha.php new file mode 100644 index 0000000..e693447 --- /dev/null +++ b/examples/funcaptcha.php @@ -0,0 +1,22 @@ +solve(new FuncaptchaRequest( + 'https://example.com/', + '69A21A01-CC7B-B9C6-0F9A-E7FA06677FFC', + $proxy, + funcaptchaApiJsSubdomain: 'example-api.arkoselabs.com', +)); +print_r($solution); diff --git a/examples/geetest_v3.php b/examples/geetest_v3.php new file mode 100644 index 0000000..a6fea27 --- /dev/null +++ b/examples/geetest_v3.php @@ -0,0 +1,17 @@ +solve(GeetestRequest::v3( + 'https://example.com/', + '81388ea1fc187e0c335c0a8907ff2625', + '2e2f0f65240058b683cb6ea21c303eea6n', // fresh challenge, valid for one solve only +)); +print_r($solution); diff --git a/examples/geetest_v4.php b/examples/geetest_v4.php new file mode 100644 index 0000000..10ef6ae --- /dev/null +++ b/examples/geetest_v4.php @@ -0,0 +1,17 @@ +solve(GeetestRequest::v4( + 'https://faucetpay.io/account/login', + '4eb8b0c2b27f3365b9244d9da81638c6', + initParameters: ['riskType' => 'slide'], +)); +print_r($solution); diff --git a/examples/hunt.php b/examples/hunt.php new file mode 100644 index 0000000..f4e42b7 --- /dev/null +++ b/examples/hunt.php @@ -0,0 +1,22 @@ +solve(HuntCustomTaskRequest::fingerprint( + 'https://example.com/', + 'https://example.com/hd-api/external/apps/a2157wab1045d68672a63557e0n2a77edbfd15ea/api.js', + $proxy, + userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36', +)); +print_r($solution); diff --git a/examples/image_to_text.php b/examples/image_to_text.php index a28fb57..da9d8af 100644 --- a/examples/image_to_text.php +++ b/examples/image_to_text.php @@ -1,6 +1,6 @@ solve($request)['text'], "\n"; +$solution = $client->solve(ImageToTextRequest::fromFile( + $argv[1] ?? __DIR__ . '/images/sample.jpg', + capMonsterModule: TextModule::UNIVERSAL, + recognizingThreshold: 50, + case: true, +)); +print_r($solution); diff --git a/examples/images/sample.jpg b/examples/images/sample.jpg new file mode 100644 index 0000000000000000000000000000000000000000..2e5c406d6e1174982bbe01070881b77df9743897 GIT binary patch literal 5087 zcmbW5cQo8jyTHF|*Y4^i2+>&~T3Efe=t1br0$ue2 ztk>%#1pP~Z{}d1b7(xhz!HI}Tt{v(r0Rj*hOaK8B5<(!?-a*&>0ECK=nq624dPCn9 z#^Fta3`@<2b1GMN(Hcx1AVlnZ!ik9KZqhT{;=0YvbB9+{Ok6@zN?PTfs+zinrk0_R zv5BdfxrM!h<0B_$m&d+O(0&;IfTt0WQPDB6&*Re4Uu0xvy?m8jP*_x4@~*V3yr#CU zzM-+Hxuv_O_fud0z~Io-^vvws{KDtO^$pzS*4OQCJG=P9Uq{C$r)R&<|KS1w;D2LX z+kb=o4=$=}E&>Py41xW_1tP#)E0_vG$Sw?}R?>&rdf(tchQVo+Q}e64h&V+I4ruLs zCW+|~qU&7vf6)F#_P+xQ|9_GF7ubJuEdr!q(DmVgsQ?Aw^qi+4RQ7ZO)~&^d}Fdajg|U4|Dhorl-idXz8Uus*dHXz$Ndh9Cfc9J zWM7KwkKboD`r$26L#+;RMx^iM;Ig-X?t|5&zhWCrR7Yu)u3^K_lFEWOFRGVUJn!mlF8bC*cn6L7RjA$}@$BH384z6TMkH z3k;GDZn;ux4`6u)V%`lOY5(5vy_6ea4I^hn*&ijLpKg3O=rs~sN9!pMwOz(Qcz&8NNWF}x{Q8?-x-wfm`;rIR#YM&eC<$l-JAy%E;XCx zZf^*S8zm)teMfw~UW^~hP zDjQ|iSLnv_^HNSl0<>#=SI3lp2?x~0nSGZ-;*UGQd88c zX}4Sa+HvFIhT?BAwgjk$mnV`QuSa7kz057d*Nz}Tk~@kliTLirbIuY~d(p`e2LUR* zHQTVSJZm0SLy&}E69=Mj^Q@?vNqKgz3LVeVaZ-%NVZhv|mv(K2?QQp3(b3ZxZH_tz zii`zhb}P;S@07@IGbfM~QMS~#{?bKei>Tg5cAeWQui>VLLcm>y>w0pVZWfvCh9{-# z96u@@ND?=IGZEb6mbIxNT-4S4?F^o51Lyk}>NU=;;e*qi_4CO&w4IrYf?tKymibA_ z>1u9Ii)}A9MmjWcd^gBaoLcpvO&*J5dnH>Y?DgPiH4Dv-A%s*N1xaY1*;np-4=6i} z+dtOq5v+gTl9S+cmVtuc_?mquvI~PYLL#jhTAsPeacZ}u8X@%{L|$#tmm;}J58zjT zyWVL-Xn=0=hW6+UbCeNYKHt;EaTPI_b>GxwEiJ~2zg=Tf$bdGps4t(+2#lZu?yJkG@sh676o0+wn1oqB8XEF*^HF6N zep{7nYxk*mnIpG%y(Ps+#JTDH)q@&O>Bi`zh4H-10NA0$q33gxVBUsVHdMPemYGRf z)MV8uk&Dsi3itw|Ef^_oLK4EZ-1Au8I^jLLOc%iBW|mg~=|PJTs>T?6l)hz*=A^=|hp>ZdK|H}li*QBuf< z9{=Twk60(`C@kob%dCH9F9L2>`o)IuvXEEe>7_H!9Lm5-;6q}tvcmrS9+#tD8bizX z4yk+IvE_iz9>Cmf3m>m=-9@8Ib=-b*5~imxawg|@h4Q2qCq!hAH|_UabI|o3F^y-r627ij(&E2U4B?4nh~pm^ifinUsPpV%c<@l`fc8 zljS4%yY&SEt~pE>>0|LK)-I+H882y$8>g?54XvvWwhdxtqYG(39VY{2N! z1z?8{bK#be1Iida0QA||byT`vKheIUwy{MO5tYm3g@nseebe~+c$5aTjbSDHOMAOU zjK1;%{~`R@GgM|A_jn+=x^A4}$p@8?L33ak+HPpmhq!I4NJbZKuCZ1U zbTT%@ETh+0)1Z@r>aFT+9-~zQ#wUn@)I@*Nc&B<6_C)lsFP~sOQv%lQFu7J0?SHrp ztnpKKZ-$^>;GpfJiT>E6zY_1i%L};ho8{N%N~M`|@=By=Z51+3{LKp}gb&5O?^^#I zD*3EwMb5S7gypu&UGvRoOq!I*J>IF{BU=!Xx*P`M>PcZ8A$?+-?JoXZBfeL{JAP*7 z$GvFtIR`yn^WJhx>e8W_dm}_+54kJd{^}l# zX+9}W8jbT%)x^0I9J*dWzm7V-)=8opA(2+44~-h4;32$zqf`cSe4fE)L*+=i=0fZb zGP9Z;G#zUjU5w9oyW!LHd#CCw%~+AM_>%ZhMX?!e#-C`Wgerj)HM#2F^OY{dJB3R` zEG~#U&70XGj2gY0)ykUp({xwYVf6H!#g)!J9ka% zp-l~3`4gYRM=Z1{ncC%CFdxUmF0nGMFci^xFaWs_*|RFiKR9%K@?4^H^Og*NT=r3xawFS)Q)Mplph1AFT-COe*45I2}XPRHl+&Vt9@>`N#!fz`S53%hp=6* zk_NP5_4^8N$of$X`JxRy!DVqmOtxhac;%CRA_mtIfBrLy5`fZO`Oz7Ddt8f=gycj zf!_0vRO<(SCwKg!1Q8hw9yEcwG~q?agd3}2972+^NferER)+d`Xb!sKTO^5hDop^X zmIs2M6Hpvn^m+%WAQ)~sD@o4R*}zgYRFsSQ6l&elSXqDB!I`NT^)izrmUy$l2$}1; z!-qoG)h9vtyTwSFleC|`HF)~gSMi3vDh(*2?5zLZOdm;6o6q zC|CHPJw~rO_i(*iiGTs&o5LHH<`2umB0J7{3f1{I%<9UcKDP9(t#`6z@Pn3xK3RrP z${H&WSnje2PUyx5iQ<=(mloFpyB$c9zih>;sgE=(i|^rV$F}zD55yx}TsD_>4#Ej8 z{#MyFFA_;1Y-(@{-f@RQ05muTPog&aRp@={CqW{|FA21r9!-+O#$jxMtu-Ju@O%4* zGOJ5w-&Uj=QJ%u@*HZ(J5~JrLWk3lwLash}+hvc&4XrQivV>h4s#i3)S~T<@r9Kr2 zm7qG7$|Itm&+$**fV8lD6v$_J!rx_Q#`%l+0Pbi!u#JH#Ye3xKpO`Bq&F0h2a)*L^ zt&-H8=P$%KsD`OBG+iD=F){$^WYeK^Rs&sw^5Dp1|_olA$&!Rk`qAiswB zrJoH0lP4^XEkGyt^Qc7cig51GPpxSIVkDwZ)sn=bl~v7@(3YBnO?nQR9#LikIgRb$$-1BIiIg0fM7h0oJB)m_Y0zP7 znNl8EW@C1f`#SJ>+VIxPqi)zW0^t)-{XSe{GS!kQr3;lYuO07H!U^T8$2|4o`wjKvMov(r11>oTX{}{${|zR-w*_`2Jeh;jhbQ6VQ@; zoHmbv)Ve2j@^?M4OT^>dHKd;{jV-2cM*0Q06h3 z4K5Ces|p870GRyfT+N#v4yVAv1PKth|qshG9_F%%VAft zYUlmH%{v^Ci5PI~VZI(o6Mj&pWes$FU+q}%3-=VEerwh<(_p2z2tr?70j@TdxP-55 zS3tiOVwOS4?Y2nzOs;c5sKA+%k*gf9ssmX>ROcl+3k^Fd{%g2QNcJ_r-m{+O0j$`( ze`~R?&8v`}e$Pl&)tkBD#Tfo4S9s~E=yiJHA;!L8b7)E9d%}Fk+V8@&VG{U#9tK$Y OaX}og^X=MPef|%_S3aTu literal 0 HcmV?d00001 diff --git a/examples/imperva.php b/examples/imperva.php new file mode 100644 index 0000000..b7c3451 --- /dev/null +++ b/examples/imperva.php @@ -0,0 +1,24 @@ +solve(new ImpervaCustomTaskRequest( + 'https://example.com/login', + incapsulaScriptUrl: '_Incapsula_Resource?SWJIYLWA=719d34d31c8e3a6e6fffd425f7e032f3', + incapsulaCookies: 'incap_ses_1166_2930313=br7iX33ZNCtf3HlpEXcuEDzz72cAAAAA0suDnBGrq/iA0J4oERYzjQ==; visid_incap_2930313=P3hgPVm9S8Oond1L0sXhZqfK72cAAAAAQUIPAAAAAABoMSY9xZ34RvRseJRiY6s+;', + proxy: $proxy, + reese84UrlEndpoint: 'Built-with-the-For-hopence-Hurleysurfecting-the-', + userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36', +)); +print_r($solution); diff --git a/examples/mtcaptcha.php b/examples/mtcaptcha.php new file mode 100644 index 0000000..6060e9b --- /dev/null +++ b/examples/mtcaptcha.php @@ -0,0 +1,13 @@ +solve(new MtCaptchaRequest('https://example.com/', 'MTPublic-abCDEFJAB')); +print_r($solution); diff --git a/examples/prosopo.php b/examples/prosopo.php new file mode 100644 index 0000000..58751b5 --- /dev/null +++ b/examples/prosopo.php @@ -0,0 +1,13 @@ +solve(new ProsopoTaskRequest('https://example.com/login', '5EZU3LG31uzq1Mwi8inwqxmfvFDpj7VzwDnZwj4Q3syyxBwV')); +print_r($solution); diff --git a/examples/proxy_usage.php b/examples/proxy_usage.php new file mode 100644 index 0000000..676475e --- /dev/null +++ b/examples/proxy_usage.php @@ -0,0 +1,22 @@ +solve(new RecaptchaV2Request( + 'https://lessons.zennolab.com/captchas/recaptcha/v2_simple.php?level=high', + '6Lcg7CMUAAAAANphynKgn9YAgA4tQ2KI_iqRyTwd', + proxy: $proxy, +)); +print_r($solution); diff --git a/examples/recaptcha_image.php b/examples/recaptcha_image.php new file mode 100644 index 0000000..8bc5f62 --- /dev/null +++ b/examples/recaptcha_image.php @@ -0,0 +1,20 @@ +solve(new RecaptchaComplexImageTaskRequest( + RecaptchaGrid::Grid3x3, + task: 'Click on traffic lights', + taskDefinition: '/m/015qff', + imageUrls: ['https://i.postimg.cc/yYjg75Kv/payloadtraffic.jpg'], + userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36', +)); +print_r($solution); diff --git a/examples/recaptcha_v2.php b/examples/recaptcha_v2.php index de3920c..a2759de 100644 --- a/examples/recaptcha_v2.php +++ b/examples/recaptcha_v2.php @@ -5,20 +5,12 @@ use Zennolab\CapMonsterCloud\CapMonsterClient; use Zennolab\CapMonsterCloud\ClientOptions; -use Zennolab\CapMonsterCloud\Exception\CapMonsterException; use Zennolab\CapMonsterCloud\Requests\RecaptchaV2Request; $client = new CapMonsterClient(new ClientOptions(getenv('CAPMONSTER_API_KEY') ?: 'YOUR_API_KEY')); -try { - printf("Balance: %.4f USD\n", $client->getBalance()); - - $solution = $client->solve(new RecaptchaV2Request( - 'https://lessons.zennolab.com/captchas/recaptcha/v2_simple.php?level=high', - '6Lcg7CMUAAAAANphynKgn9YAgA4tQ2KI_iqRyTwd', - )); - echo 'gRecaptchaResponse: ', $solution['gRecaptchaResponse'], "\n"; -} catch (CapMonsterException $e) { - fwrite(STDERR, get_class($e) . ': ' . $e->getMessage() . "\n"); - exit(1); -} +$solution = $client->solve(new RecaptchaV2Request( + 'https://lessons.zennolab.com/captchas/recaptcha/v2_simple.php?level=high', + '6Lcg7CMUAAAAANphynKgn9YAgA4tQ2KI_iqRyTwd', +)); +print_r($solution); diff --git a/examples/recaptcha_v2_enterprise.php b/examples/recaptcha_v2_enterprise.php new file mode 100644 index 0000000..db2bd4a --- /dev/null +++ b/examples/recaptcha_v2_enterprise.php @@ -0,0 +1,17 @@ +solve(new RecaptchaV2EnterpriseRequest( + 'https://example.com/', + '6Lf26sUnAAAAAIKLuWNYgRsFUfmI-3Lex3xT5N-s', + enterprisePayload: ['s' => 'SOME_ADDITIONAL_TOKEN'], +)); +print_r($solution); diff --git a/examples/recaptcha_v3.php b/examples/recaptcha_v3.php new file mode 100644 index 0000000..e454f5b --- /dev/null +++ b/examples/recaptcha_v3.php @@ -0,0 +1,17 @@ +solve(new RecaptchaV3ProxylessRequest( + 'https://lessons.zennolab.com/captchas/recaptcha/v3.php?level=beta', + '6Le0xVgUAAAAAIt20XEB4rVhYOODgTl00d8juDob', + minScore: 0.9, +)); +print_r($solution); diff --git a/examples/recaptcha_v3_enterprise.php b/examples/recaptcha_v3_enterprise.php new file mode 100644 index 0000000..ea402e4 --- /dev/null +++ b/examples/recaptcha_v3_enterprise.php @@ -0,0 +1,18 @@ +solve(new RecaptchaV3EnterpriseRequest( + 'https://lessons.zennolab.com/captchas/recaptcha/v3.php?level=beta', + '6Le0xVgUAAAAAIt20XEB4rVhYOODgTl00d8juDob', + minScore: 0.9, + pageAction: 'submit', +)); +print_r($solution); diff --git a/examples/recognition_complex_image.php b/examples/recognition_complex_image.php new file mode 100644 index 0000000..6a942c7 --- /dev/null +++ b/examples/recognition_complex_image.php @@ -0,0 +1,22 @@ + ... (e.g. oocl_rotate_double_new bg.png ring.png circle.png) +require __DIR__ . '/../vendor/autoload.php'; + +use Zennolab\CapMonsterCloud\CapMonsterClient; +use Zennolab\CapMonsterCloud\ClientOptions; +use Zennolab\CapMonsterCloud\Requests\RecognitionComplexImageTaskRequest; + +if ($argc < 3) { + fwrite(STDERR, "usage: php examples/recognition_complex_image.php ...\n"); + exit(1); +} +[$task, $images] = [$argv[1], array_slice($argv, 2)]; + +$client = new CapMonsterClient(new ClientOptions(getenv('CAPMONSTER_API_KEY') ?: 'YOUR_API_KEY')); + +$solution = $client->solve(new RecognitionComplexImageTaskRequest( + array_map(static fn (string $path): string => base64_encode((string) file_get_contents($path)), $images), + $task, +)); +print_r($solution); diff --git a/examples/tendi.php b/examples/tendi.php new file mode 100644 index 0000000..05fc06c --- /dev/null +++ b/examples/tendi.php @@ -0,0 +1,13 @@ +solve(new TenDiCustomTaskRequest('https://example.com/', '189123456')); +print_r($solution); diff --git a/examples/tspd.php b/examples/tspd.php new file mode 100644 index 0000000..82ec57b --- /dev/null +++ b/examples/tspd.php @@ -0,0 +1,23 @@ +solve(new TspdCustomTaskRequest( + 'https://example.com/', + tspdCookie: 'TS386a400d029=08267010245; TS386a400d078=08267dbb3b0c', + htmlPageBase64: 'PCFET0NUWVBFIGh0bWw+PC9odG1sPg==', // the whole TSPD challenge page in base64 + proxy: $proxy, + userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36', +)); +print_r($solution); diff --git a/examples/turnstile.php b/examples/turnstile.php new file mode 100644 index 0000000..f0c66ea --- /dev/null +++ b/examples/turnstile.php @@ -0,0 +1,13 @@ +solve(TurnstileRequest::standard('http://tsmanaged.zlsupport.com/', '0x4AAAAAAABUYP0XeMJF0xoy')); +print_r($solution); diff --git a/examples/turnstile_cf_clearance.php b/examples/turnstile_cf_clearance.php new file mode 100644 index 0000000..ecc3c3f --- /dev/null +++ b/examples/turnstile_cf_clearance.php @@ -0,0 +1,23 @@ +solve(TurnstileRequest::challengeCookie( + 'https://example.com/', + '0x4AAAAAAADnPIDROrmt1Wwj', + htmlPageBase64: 'PCFET0NUWVBFIGh0bWw+PC9odG1sPg==', // base64 of the 403 "Just a moment..." page + userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36', + proxy: $proxy, +)); +print_r($solution); diff --git a/examples/turnstile_waitroom.php b/examples/turnstile_waitroom.php new file mode 100644 index 0000000..b1dd868 --- /dev/null +++ b/examples/turnstile_waitroom.php @@ -0,0 +1,23 @@ +solve(TurnstileRequest::waitRoom( + 'https://example.com/', + '0x4AAAAAAADnPIDROrmt1Wwj', + htmlPageBase64: 'PCFET0NUWVBFIGh0bWw+PC9odG1sPg==', // base64 of the waiting room page + userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36', + proxy: $proxy, +)); +print_r($solution); diff --git a/examples/turnstile_with_proxy.php b/examples/turnstile_with_proxy.php deleted file mode 100644 index dfc68ea..0000000 --- a/examples/turnstile_with_proxy.php +++ /dev/null @@ -1,23 +0,0 @@ -solve(TurnstileRequest::standard( - 'https://target-website.com/login', - '0x4AAAAAAABnPIDnK2k_e-2', - proxy: $proxy, -)); -// put $solution['token'] into the cf-turnstile-response field and submit the form -echo 'Turnstile token: ', $solution['token'], "\n"; diff --git a/examples/yidun.php b/examples/yidun.php new file mode 100644 index 0000000..d80994c --- /dev/null +++ b/examples/yidun.php @@ -0,0 +1,13 @@ +solve(new YidunRequest('https://example.com/', '6cw0f0485d5d46auacf9b735d20218a5')); +print_r($solution); From d928f4ebd09f80710460cc765760443d8f5b1b95 Mon Sep 17 00:00:00 2001 From: "pavel.grinkevich" Date: Tue, 27 Oct 2026 12:39:40 +0000 Subject: [PATCH 3/3] README: read the Quick Start image from a file The placeholder string 'base64_captcha_image' made the API reject the task with ERROR_ZERO_CAPTCHA_FILESIZE, so the snippet stopped before reCAPTCHA. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 8260d5d..d0e26d2 100644 --- a/README.md +++ b/README.md @@ -101,7 +101,7 @@ $client = new CapMonsterClient(new ClientOptions('your_client_key')); echo $client->getBalance(), PHP_EOL; // solve image captcha -$imageResult = $client->solve(new ImageToTextRequest('base64_captcha_image')); +$imageResult = $client->solve(ImageToTextRequest::fromFile('captcha.png')); echo $imageResult['text'], PHP_EOL; // solve reCAPTCHA v2 (without proxy)