From 13685825bf1e059b153221f79e403f0cc9765215 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9=20=D0=93=D0=BB?= =?UTF-8?q?=D0=B0=D0=B4=D1=8B=D1=88=D0=B5=D0=B2?= Date: Sat, 19 Dec 2015 23:47:08 +0300 Subject: [PATCH] Rucaptcha public client --- .gitignore | 13 + LICENSE | 21 ++ README.md | 56 +++++ composer.json | 29 +++ phpunit.xml | 15 ++ src/Client.php | 82 +++++++ src/ConfigurableTrait.php | 34 +++ src/Error.php | 40 ++++ src/Exception/InvalidArgumentException.php | 11 + src/Exception/RucaptchaException.php | 13 + src/Exception/RuntimeException.php | 11 + src/Extra.php | 25 ++ src/GenericClient.php | 261 +++++++++++++++++++++ src/Logger.php | 41 ++++ tests/functional/ClientTest.php | 91 +++++++ tests/resources/seopult_captcha.png | Bin 0 -> 3550 bytes tests/resources/yandex_captcha.gif | Bin 0 -> 3549 bytes 17 files changed, 743 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 composer.json create mode 100644 phpunit.xml create mode 100644 src/Client.php create mode 100644 src/ConfigurableTrait.php create mode 100644 src/Error.php create mode 100644 src/Exception/InvalidArgumentException.php create mode 100644 src/Exception/RucaptchaException.php create mode 100644 src/Exception/RuntimeException.php create mode 100644 src/Extra.php create mode 100644 src/GenericClient.php create mode 100644 src/Logger.php create mode 100644 tests/functional/ClientTest.php create mode 100644 tests/resources/seopult_captcha.png create mode 100644 tests/resources/yandex_captcha.gif diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..91c8d69 --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +# JetBrains cool IDE +/.idea + +# Composer +/vendor/ +/composer.phar +/composer.lock + +# PHPUnit library +phpunit.phar + +# private files +/tests/private/ \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..1fc1273 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2015 Dmitry Gladyshev + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..4a6d0ea --- /dev/null +++ b/README.md @@ -0,0 +1,56 @@ +# rucaptcha-client # + +**PHP 5.6** + +## Пример ## + +```php +$rucaptcha = new Rucaptcha\Client('YOUR_API_KEY', [ + 'verbose' => true +]); + +$captchaText = $rucaptcha->recognizeFile('/captcha.png', [ + 'regsense' => 1 +]); + +print_r($captchaText); +``` + +## Методы `Rucaptcha\Client` ## + +```php +Client Client::setOptions(array $options); +string Client::recognize(string $content, array $extra = []); +string Client::recognizeFile(string $path, array $extra = []); +string Client::getLastCaptchaId(); +string Client::getBalance(); +bool Client::badCaptcha(string $captchaId); +array Client::getLoad(array $paramsList = []); +``` +## Опции клиента ## + +Параметр | Тип | По умолчанию | Возможные значения +---| --- | --- | --- +`verbose` | bool | false | Включает/отключает логирование +`apiKey`| string | '' | Ключ API с которым вызывается сервис +`rTimeout`| integer | 5 | Период между опросами серевера при получении результата распознавания +`mTimeout` | integer | 120 | Таймаут ожидания ответа при получении результата распознавания +`serverBaseUri`| string | 'http://rucaptcha.com' | Базовый URI сервиса + +## Параметры распознавания капчи `$extra` ## + +Параметр | Тип | По умолчанию | Возможные значения +---| --- | --- | --- +`phrase` | integer | 0 | 0 = одно слово
1 = капча имеет два слова +`regsense`| integer | 0 | 0 = регистр ответа не имеет значения
1 = регистр ответа имеет значение +`question`| integer | 0 | 0 = параметр не задействован
1 = на изображении задан вопрос, работник должен написать ответ +`numeric` | integer | 0 | 0 = параметр не задействован
1 = капча состоит только из цифр
2 = Капча состоит только из букв
3 = Капча состоит либо только из цифр, либо только из букв. +`calc`| integer | 0 | 0 = параметр не задействован
1 = работнику нужно совершить математическое действие с капчи +`min_len` | 0..20 | 0 | 0 = параметр не задействован
1..20 = минимальное количество знаков в ответе +`max_len` | 1..20 | 0 | 0 = параметр не задействован
1..20 = максимальное количество знаков в ответе +`is_russian` | integer | 0 | параметр больше не используется, т.к. он означал "слать данную капчу русским исполнителям", а в системе находятся только русскоязычные исполнители. Смотрите новый параметр language, однозначно обозначающий язык капчи +`soft_id` | string | | ID разработчика приложения. Разработчику приложения отчисляется 10% от всех капч, пришедших из его приложения. +`language` | integer | 0 | 0 = параметр не задействован
1 = на капче только кириллические буквы
2 = на капче только латинские буквы +`header_acao` | integer | 0 | 0 = значение по умолчанию
1 = in.php передаст Access-Control-Allow-Origin: * параметр в заголовке ответа. (Необходимо для кросс-доменных AJAX запросов в браузерных приложениях. Работает также для res.php.) +`textinstructions` | string | |Текст, который будет показан работнику. Может содержать в себе инструкции по разгадке капчи. Ограничение - 140 символов. Текст необходимо слать в кодировке UTF-8. +`textcaptcha` | string | | Текстовая капча. Картинка при этом не загружается, работник получает только текст и вводит ответ на этот текст. Ограничение - 140 символов. Текст необходимо слать в кодировке UTF-8. \ No newline at end of file diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..9a49259 --- /dev/null +++ b/composer.json @@ -0,0 +1,29 @@ +{ + "name": "gladyshev/rucaptcha-client", + "keywords": ["php", "library", "client", "captcha recognition", "rucaptcha"], + "minimum-stability": "stable", + "type": "library", + "license": "MIT", + "authors": [ + { + "name": "Dmitry Gladyshev", + "email": "deel@email.com", + "homepage": "https://github.com/gladyshev" + } + ], + "require": { + "php" : ">=5.6", + "guzzlehttp/guzzle": "6.1.*", + "psr/log": "*" + }, + "autoload": { + "psr-4": { + "Rucaptcha\\" : "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Rucaptcha\\tests\\" : "tests/" + } + } +} \ No newline at end of file diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..8fc8089 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,15 @@ + + + + ./tests/functional + + + \ No newline at end of file diff --git a/src/Client.php b/src/Client.php new file mode 100644 index 0000000..ee7359b --- /dev/null +++ b/src/Client.php @@ -0,0 +1,82 @@ + + */ + +namespace Rucaptcha; + + +use Rucaptcha\Exception\RuntimeException; + +class Client extends GenericClient +{ + const STATUS_OK_REPORT_RECORDED = 'OK_REPORT_RECORDED'; + + /** + * @var string + */ + protected $serverBaseUri = 'http://rucaptcha.com'; + + /** + * @var string + */ + private $softId = ''; + + /** + * @inheritdoc + */ + public function recognize($content, array $extra = []) + { + if ($this->softId !== '') { + $extra[Extra::SOFT_ID] = $this->softId; + } + return parent::recognize($content, $extra); + } + + /** + * @return string + */ + public function getBalance() + { + $response = $this->getClient()->request('GET', "/res.php?key={$this->apiKey}&action=getbalance"); + return $response->getBody()->__toString(); + } + + /** + * @param $captchaId + * @return bool + * @throws RuntimeException + */ + public function badCaptcha($captchaId) + { + $response = $this->getClient()->request('GET', "/res.php?action=reportbad&id={$captchaId}"); + if ($response->getBody()->__toString() === self::STATUS_OK_REPORT_RECORDED) + return true; + + throw new RuntimeException('Report sending trouble: ' . $response->getBody() . '.'); + } + + /** + * @param array $paramsList + * @return array + */ + public function getLoad(array $paramsList = ['waiting', 'load', 'minbid', 'averageRecognitionTime']) + { + $response = $this->getClient()->request('GET', "/load.php"); + $responseText = $response->getBody()->__toString(); + $statusData = []; + + foreach ($paramsList as $item) { + // Fast parse tags + $value = substr($responseText, + strpos($responseText, '<' . $item . '>') + mb_strlen('<' . $item . '>'), + strpos($responseText, '') - strpos($responseText, '<' . $item . '>') - mb_strlen('<' . $item . '>') + ); + + if ($value !== false) { + $statusData[$item] = $value; + } + } + return $statusData; + } +} \ No newline at end of file diff --git a/src/ConfigurableTrait.php b/src/ConfigurableTrait.php new file mode 100644 index 0000000..992f5a9 --- /dev/null +++ b/src/ConfigurableTrait.php @@ -0,0 +1,34 @@ + + */ + +namespace Rucaptcha; + + +use Rucaptcha\Exception\InvalidArgumentException; + +trait ConfigurableTrait +{ + public function setOptions(array $properties, $ignoreMissingProperties = false) + { + foreach ($properties as $property => $value) { + $setter = 'set' . ucfirst($property); + + if (method_exists($this, $setter)) { + $this->$setter($value); + continue; + } + + if (property_exists($this, $property)) { + $this->$property = $value; + continue; + } + + if (!$ignoreMissingProperties) { + throw new InvalidArgumentException("Property `{$property}` not found in class `" . __CLASS__ . "`."); + } + } + return $this; + } +} \ No newline at end of file diff --git a/src/Error.php b/src/Error.php new file mode 100644 index 0000000..32ecea0 --- /dev/null +++ b/src/Error.php @@ -0,0 +1,40 @@ + + */ + +namespace Rucaptcha; + + +class Error +{ + const KEY_DOES_NOT_EXIST = 'ERROR_KEY_DOES_NOT_EXIST'; + const WRONG_ID_FORMAT = 'ERROR_WRONG_ID_FORMAT'; + const ZERO_BALANCE = 'ERROR_ZERO_BALANCE'; + const CAPTCHA_UNSOLVABLE = 'ERROR_CAPTCHA_UNSOLVABLE'; + const NO_SLOT_AVAILABLE = 'ERROR_NO_SLOT_AVAILABLE'; + const WRONG_CAPTCHA_ID = 'ERROR_WRONG_CAPTCHA_ID'; + const ZERO_CAPTCHA_FILESIZE = 'ERROR_ZERO_CAPTCHA_FILESIZE'; + const BAD_DUPLICATES = 'ERROR_BAD_DUPLICATES'; + const TOO_BIG_CAPTCHA_FILESIZE = 'ERROR_TOO_BIG_CAPTCHA_FILESIZE'; + const WRONG_FILE_EXTENSION = 'ERROR_WRONG_FILE_EXTENSION'; + const IMAGE_TYPE_NOT_SUPPORTED = 'ERROR_IMAGE_TYPE_NOT_SUPPORTED'; + const IP_NOT_ALLOWED = 'ERROR_IP_NOT_ALLOWED'; + const IP_BANNED = 'ERROR_IP_BANNED'; + + static $messages = [ + self::KEY_DOES_NOT_EXIST => 'Использован несуществующий key.', + self::WRONG_ID_FORMAT => 'Неверный формат ID капчи. ID должен содержать только цифры.', + self::ZERO_BALANCE => 'Баланс Вашего аккаунта нулевой.', + self::CAPTCHA_UNSOLVABLE => 'Капчу не смогли разгадать 3 разных работника. Списанные средства за это изображение возвращаются обратно на баланс.', + self::NO_SLOT_AVAILABLE => 'Текущая ставка распознования выше, чем максимально установленная в настройках Вашего аккаунта. Либо на сервере скопилась очередь и работники не успевают её разобрать, повторите загрузку через 5 секунд.', + self::WRONG_CAPTCHA_ID => 'Вы пытаетесь получить ответ на капчу или пожаловаться на капчу, которая была загружена более 15 минут назад.', + self::ZERO_CAPTCHA_FILESIZE => 'Размер капчи меньше 100 Байт.', + self::BAD_DUPLICATES => 'Ошибка появляется при включённом 100%м распознании. Было использовано максимальное количество попыток, но необходимое количество одинаковых ответов не было набрано.', + self::TOO_BIG_CAPTCHA_FILESIZE => 'Размер капчи более 100 КБайт.', + self::WRONG_FILE_EXTENSION => 'Ваша капча имеет неверное расширение, допустимые расширения jpg,jpeg,gif,png.', + self::IMAGE_TYPE_NOT_SUPPORTED => 'Сервер не может определить тип файла капчи.', + self::IP_NOT_ALLOWED => 'В Вашем аккаунте настроено ограничения по IP с которых можно делать запросы. И IP, с которого пришёл данный запрос не входит в список разрешённых.', + self::IP_BANNED => 'IP-адрес, с которого пришёл запрос заблокирован из-за частых обращений с различными неверными ключами. Блокировка снимается через час.', + ]; +} \ No newline at end of file diff --git a/src/Exception/InvalidArgumentException.php b/src/Exception/InvalidArgumentException.php new file mode 100644 index 0000000..e904916 --- /dev/null +++ b/src/Exception/InvalidArgumentException.php @@ -0,0 +1,11 @@ + + */ + +namespace Rucaptcha\Exception; + + +class InvalidArgumentException extends RucaptchaException +{ +} \ No newline at end of file diff --git a/src/Exception/RucaptchaException.php b/src/Exception/RucaptchaException.php new file mode 100644 index 0000000..0059ed8 --- /dev/null +++ b/src/Exception/RucaptchaException.php @@ -0,0 +1,13 @@ + + */ + +namespace Rucaptcha\Exception; + + +use Exception; + +class RucaptchaException extends Exception +{ +} \ No newline at end of file diff --git a/src/Exception/RuntimeException.php b/src/Exception/RuntimeException.php new file mode 100644 index 0000000..cb377b2 --- /dev/null +++ b/src/Exception/RuntimeException.php @@ -0,0 +1,11 @@ + + */ + +namespace Rucaptcha\Exception; + + +class RuntimeException extends RucaptchaException +{ +} \ No newline at end of file diff --git a/src/Extra.php b/src/Extra.php new file mode 100644 index 0000000..d378d9c --- /dev/null +++ b/src/Extra.php @@ -0,0 +1,25 @@ + + */ + +namespace Rucaptcha; + + +class Extra +{ + const PHRASE = 'phrase'; + const REGSENSE = 'regsense'; + const QUESTION = 'question'; + const NUMERIC = 'numeric'; + const CALC = 'calc'; + const MIN_LEN = 'min_len '; + const MAX_LEN = 'max_len '; + const IS_RUSSIAN = 'is_russian'; + const SOFT_ID = 'soft_id'; + const LANGUAGE = 'language'; + const HEADER_ACAO = 'header_acao '; + const TEXTINSTRUCTIONS = 'textinstructions'; + const TEXTCAPTCHA = 'textcaptcha'; + const CONTENT_TYPE = 'content_type'; +} \ No newline at end of file diff --git a/src/GenericClient.php b/src/GenericClient.php new file mode 100644 index 0000000..3f13eaf --- /dev/null +++ b/src/GenericClient.php @@ -0,0 +1,261 @@ + + */ + +namespace Rucaptcha; + + +use GuzzleHttp\Client as GuzzleClient; +use GuzzleHttp\ClientInterface; +use GuzzleHttp\RequestOptions; +use Psr\Log\LoggerAwareInterface; +use Psr\Log\LoggerAwareTrait; +use Psr\Log\LoggerInterface; +use Rucaptcha\Exception\InvalidArgumentException; +use Rucaptcha\Exception\RucaptchaException; +use Rucaptcha\Exception\RuntimeException; + +class GenericClient implements LoggerAwareInterface +{ + use LoggerAwareTrait; + use ConfigurableTrait; + + /* Statuses */ + const STATUS_OK = 'OK'; + const STATUS_CAPTCHA_NOT_READY = 'CAPCHA_NOT_READY'; + + /** + * @var string + */ + public $lastCaptchaId = ''; + + /** + * @var string + */ + protected $serverBaseUri = ''; + + /** + * @var bool + */ + protected $verbose = false; + + /** + * @var string + */ + protected $apiKey = ''; + + /** + * @var int + */ + protected $rTimeout = 5; + + /** + * @var int + */ + protected $mTimeout = 120; + + /** + * @var ClientInterface + */ + private $client = null; + + /** + * @param array $options + * @param string $apiKey + */ + public function __construct($apiKey, array $options = []) + { + $this->apiKey = $apiKey; + $this->setOptions($options); + } + + /** + * @param $path + * @param array $extra + * @return string + * @throws RucaptchaException + */ + public function recognizeFile($path, array $extra = []) + { + if (!file_exists($path)) { + throw new InvalidArgumentException("Captcha file `$path` not found."); + } + + $fp = fopen($path, 'r'); + + $content = ''; + + while (!feof($fp)) { + $content .= fgets($fp, 1024); + } + + fclose($fp); + + if (isset($extra[Extra::CONTENT_TYPE])) { + $extension = self::resolveFileExtension($path); + $extra[Extra::CONTENT_TYPE] = self::resolveContentType($extension); + } + + return $this->recognize($content, $extra); + } + + /** + * @param ClientInterface $client + * @return $this + */ + public function setClient(ClientInterface $client) + { + $this->client = $client; + + return $this; + } + + /** + * @return ClientInterface + */ + public function getClient() + { + if ($this->client === null) { + $this->client = new GuzzleClient(['base_uri' => $this->serverBaseUri]); + } + return $this->client; + } + + /** + * @return LoggerInterface + */ + protected function getLogger() + { + if ($this->logger === null) { + $this->setLogger(new Logger($this->verbose)); + } + return $this->logger; + } + + /** + * @param $content + * @param array $extra + * @return string + * @throws RuntimeException + */ + public function recognize($content, array $extra = []) + { + /* Send image to recognition server */ + + $this->getLogger()->info("Try send captcha image on {$this->serverBaseUri}/in.php"); + + $response = $this->getClient()->request('POST', '/in.php', [ + RequestOptions::HEADERS => [ + 'Content-Type' => 'application/x-www-form-urlencoded' + ], + RequestOptions::FORM_PARAMS => array_merge($extra, [ + 'method' => 'base64', + 'key' => $this->apiKey, + 'body' => base64_encode($content) + ]) + ]); + + $responseText = $response->getBody()->__toString(); + + if (strpos($responseText, 'ERROR') !== false + || strpos($responseText, '') !== false + || strpos($responseText, '|') === false + || in_array($responseText, array_keys(Error::$messages)) + ) { + throw new RuntimeException($this->getErrorMessage($responseText) ?: "Unknown error: `{$responseText}`."); + } + + + /* Get captcha recognition result */ + + list($status, $captchaId) = explode("|", $responseText); + + $this->getLogger()->info("Sending success. Got captcha id `$captchaId`."); + + $startTime = time(); + + $this->lastCaptchaId = $captchaId; + + while (true) { + unset($response, $responseText, $status); + + $this->getLogger()->info("Waiting {$this->rTimeout} sec."); + + sleep($this->rTimeout); + + if (time() - $startTime >= $this->mTimeout) { + throw new RuntimeException("Captcha waiting timeout."); + } + + $response = $this->getClient()->request('GET', "/res.php?key={$this->apiKey}&action=get&id={$captchaId}"); + + $responseText = $response->getBody()->__toString(); + + if ($responseText === self::STATUS_CAPTCHA_NOT_READY) { + continue; + } + + if (strpos($responseText, 'OK|') !== false) { + + $this->getLogger()->info("Got OK response: {$responseText}. Elapsed " . (time() - $startTime) . ' sec.'); + + list($status, $captchaText) = explode('|', $responseText); + + return html_entity_decode(trim($captchaText)); + } + throw new RuntimeException($this->getErrorMessage($responseText) ?: "Unknown error: `{$responseText}`."); + } + } + + /** + * @param string $responseText + * @return false|string + */ + protected function getErrorMessage($responseText) + { + return isset(Error::$messages[$responseText]) + ? Error::$messages[$responseText] + : false; + } + + /** + * @param string $extension + * @return string + * @throws RucaptchaException + */ + protected static function resolveContentType($extension) + { + // ToDo: refactor this bullshit + + if (empty($extension)) { + throw new InvalidArgumentException("The type of content cannot be detected, because file extension is empty."); + } + + switch ($extension) { + case 'jpeg': + case 'jpg': + return "image/pjpeg"; + + default: + return 'image/' . $extension; + } + } + + /** + * @param string $path + * @param string $delimiter + * @return string + * @throws InvalidArgumentException + */ + protected static function resolveFileExtension($path, $delimiter = '.') + { + //ToDo: use SPL helper + + if (($position = strrpos($path, $delimiter)) === false) { + throw new InvalidArgumentException("Could not resolve file `{$path}` extension."); + } + + return strtolower(substr($path, ++$position)); + } +} \ No newline at end of file diff --git a/src/Logger.php b/src/Logger.php new file mode 100644 index 0000000..9daaf48 --- /dev/null +++ b/src/Logger.php @@ -0,0 +1,41 @@ + + */ + +namespace Rucaptcha; + + +use Psr\Log\AbstractLogger; +use Psr\Log\LogLevel; + +class Logger extends AbstractLogger +{ + /** + * @var bool + */ + private $verbose; + + /** + * Logger constructor. + * @param bool $verbose + */ + public function __construct(&$verbose) + { + $this->verbose =& $verbose; + } + + public function log($level, $message, array $context = []) + { + if ($this->verbose) + { + $entry = date("d/m/y H:i:s").' ['.$level.'] '.$message.PHP_EOL; + + file_put_contents('php://stdout', $entry); + + if ($level === LogLevel::ERROR) { + // file_put_contents('php://stderr', $entry); + } + } + } +} \ No newline at end of file diff --git a/tests/functional/ClientTest.php b/tests/functional/ClientTest.php new file mode 100644 index 0000000..8fa4667 --- /dev/null +++ b/tests/functional/ClientTest.php @@ -0,0 +1,91 @@ + + */ + +namespace Rucaptcha\tests\functional; + + +use Rucaptcha\Client; +use Rucaptcha\Extra; + +class ClientTest extends \PHPUnit_Framework_TestCase +{ + /** + * @var Client + */ + protected $client; + + /** + * @var string + */ + protected $yandexCaptchaImage; + + /** + * @var string + */ + protected $yandexCaptchaText; + + /** + * @var string + */ + protected $seopultCaptchaImage; + + /** + * @var string + */ + protected $seopultCaptchaText; + + + public function setUp() + { + $key = file_get_contents(__DIR__ . DIRECTORY_SEPARATOR . '..' . DIRECTORY_SEPARATOR . 'private' . DIRECTORY_SEPARATOR .'apikey'); + + $this->yandexCaptchaImage = __DIR__ . DIRECTORY_SEPARATOR . '..' . DIRECTORY_SEPARATOR . 'resources' . DIRECTORY_SEPARATOR . 'yandex_captcha.gif'; + $this->yandexCaptchaText = "915427"; + + $this->seopultCaptchaImage = __DIR__ . DIRECTORY_SEPARATOR . '..' . DIRECTORY_SEPARATOR . 'resources' . DIRECTORY_SEPARATOR . 'seopult_captcha.png'; + $this->seopultCaptchaText = 'DZTY'; + + $this->client = new Client($key, [ + 'verbose' => true + ]); + } + + + public function testYandexCaptchaRecognition() + { + $recognizedText = $this->client->recognizeFile($this->yandexCaptchaImage, [ + Extra::REGSENSE => 0 + ]); + + $this->assertEquals($this->yandexCaptchaText, $recognizedText); + } + + + public function testSeopultCaptchaRecognition() + { + $recognizedText = $this->client->recognizeFile($this->seopultCaptchaImage, [ + Extra::REGSENSE => 1 + ]); + + $this->assertEquals($this->seopultCaptchaText, $recognizedText); + } + + + public function testLoadStatistics() + { + $data = $this->client->getLoad(); + $this->assertArrayHasKey('waiting', $data); + $this->assertArrayHasKey('load', $data); + $this->assertArrayHasKey('minbid', $data); + $this->assertArrayHasKey('averageRecognitionTime', $data); + } + + + public function testGetAccountBalance() + { + $data = $this->client->getBalance(); + $this->assertNotEmpty($data); + } +} diff --git a/tests/resources/seopult_captcha.png b/tests/resources/seopult_captcha.png new file mode 100644 index 0000000000000000000000000000000000000000..4b8eb54497e85df18328b9934042cff798173def GIT binary patch literal 3550 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 Y4hPt*40%#*B>(^b07*qoM6N<$f`3xuYybcN literal 0 HcmV?d00001 diff --git a/tests/resources/yandex_captcha.gif b/tests/resources/yandex_captcha.gif new file mode 100644 index 0000000000000000000000000000000000000000..e47162d2574cd403dff96dba74a566a7f6402d1f GIT binary patch literal 3549 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