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..d0e26d2 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(ImageToTextRequest::fromFile('captcha.png'));
+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/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
new file mode 100644
index 0000000..da9d8af
--- /dev/null
+++ b/examples/image_to_text.php
@@ -0,0 +1,19 @@
+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 0000000..2e5c406
Binary files /dev/null and b/examples/images/sample.jpg differ
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/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/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
new file mode 100644
index 0000000..a2759de
--- /dev/null
+++ b/examples/recaptcha_v2.php
@@ -0,0 +1,16 @@
+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/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);
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);
+ }
+}