From ddb6dbc44c660b47e365acef299f72f958f13a78 Mon Sep 17 00:00:00 2001 From: Leonid Sheikman Date: Thu, 27 Jan 2022 22:18:01 +0300 Subject: [PATCH] initial commit --- LICENSE | 28 +++ README.md | 162 +++++++++++++ composer.json | 57 +++++ example/index.php | 48 ++++ phpcs.xml | 128 +++++++++++ src/WbApiClient.php | 499 +++++++++++++++++++++++++++++++++++++++++ src/WbApiInterface.php | 59 +++++ 7 files changed, 981 insertions(+) create mode 100644 LICENSE create mode 100644 README.md create mode 100644 composer.json create mode 100644 example/index.php create mode 100644 phpcs.xml create mode 100644 src/WbApiClient.php create mode 100644 src/WbApiInterface.php diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..07697da --- /dev/null +++ b/LICENSE @@ -0,0 +1,28 @@ +The BSD 3-Clause License + +Copyright (c) 2022 Leonid Sheikman (leonid74) All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. +IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, +INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE +OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED +OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..e5b7a85 --- /dev/null +++ b/README.md @@ -0,0 +1,162 @@ +# (English) Wildberries REST API statistics client library with throttling requests +## Русское описание ниже + +A simple Wildberries REST API statistics client library with throttling requests (for example, no more than 10 requests per second according to API rules) and an example for PHP. + +Statistics API Documentation [Wildberries REST API statistics Documentation](https://images.wbstatic.net/portal/education/Kak_rabotat'_s_servisom_statistiki.pdf) +New API Documentation [Wildberries REST API Documentation](https://suppliers-api.wildberries.ru/swagger/index.html) + +### Installing + +Via Composer: + +```bash +composer require Leonid74/wildberries-api-php +``` + +### Usage + +```php +'; +$dateFrom = '01-01-2022'; +$dateTo = '19-01-2022'; + +try { + // Create new client + $WbApiClient = new WbApiClient( $token ); + + // DEBUG level can be one of: DEBUG_NONE (default) or DEBUG_URL, DEBUG_HEADERS, DEBUG_CONTENT + // no debug + // $WbApiClient->debugLevel = WbApiClient::DEBUG_NONE; + // only URL level debug + // $WbApiClient->debugLevel = WbApiClient::DEBUG_URL; + // only HEADERS level debug + // $WbApiClient->debugLevel = WbApiClient::DEBUG_HEADERS; + // max level of debug messages to STDOUT + // $WbApiClient->debugLevel = WbApiClient::DEBUG_CONTENT; + $WbApiClient->debugLevel = WbApiClient::DEBUG_URL; + + // set the trottling of HTTP requests to 2 per second + $WbApiClient->throttle = 2; +} catch ( Exception $e ) { + die( "Critical exception when creating ApiClient: ({$e->getCode()}) " . $e->getMessage() ); +} + +/* + * Example: Get the sales + */ +$sales = $WbApiClient->sales( $dateFrom ); +if ( isset( $sales->is_error ) ) { + echo "\nError: " . implode( '; ', $sales->errors ); +} else { + var_dump( $sales ); +} + +/* + * Example: Get the report detail by period + */ +$reportDetailByPeriod = $WbApiClient->reportDetailByPeriod( $dateFrom, $dateTo ); +if ( isset( $reportDetailByPeriod->is_error ) ) { + echo "\nError: " . implode( '; ', $reportDetailByPeriod->errors ); +} else { + var_dump( $reportDetailByPeriod ); +} + +// You can set a common date (dateFrom) via the setDateFrom() function and then access other functions without passing the date +$WbApiClient->setDateFrom( $dateFrom ); +$sales = $WbApiClient->sales(); +$incomes = $WbApiClient->incomes(); + +``` + +# (Russian) Клиентская REST API библиотека статистики Wildberries с регулированием запросов + +Простая клиентская REST API библиотека статистики Wildberries с регулированием запросов (например, не более 10 запросов в секунду в соответствии с правилами API) и примером для PHP. + +Описание API [Wildberries REST API statistics](https://images.wbstatic.net/portal/education/Kak_rabotat'_s_servisom_statistiki.pdf) +Описание нового API [Wildberries REST API](https://suppliers-api.wildberries.ru/swagger/index.html) + +### Установка + +Через Composer: + +```bash +composer require Leonid74/wildberries-api-php +``` + +### Использование + +```php +'; +$dateFrom = '01-01-2022'; +$dateTo = '19-01-2022'; + +try { + // Создаем новый клиент + $WbApiClient = new WbApiClient( $token ); + + // Уровень DEBUG может быть одним из: DEBUG_NONE (по умолчанию) или DEBUG_URL, DEBUG_HEADERS, DEBUG_CONTENT + // без вывода отладочной информации + // $WbApiClient->debugLevel = WbApiClient::DEBUG_NONE; + // выводим только URL запросов/ответов + // $WbApiClient->debugLevel = WbApiClient::DEBUG_URL; + // выводим только URL и заголовки запросов/ответов + // $WbApiClient->debugLevel = WbApiClient::DEBUG_HEADERS; + // выводим URL, заголовки и всю остальную информацию запросов/ответов в STDOUT + // $WbApiClient->debugLevel = WbApiClient::DEBUG_CONTENT; + $WbApiClient->debugLevel = WbApiClient::DEBUG_URL; + + // Устанавливаем троттлинг в 2 запроса в секунду + $WbApiClient->throttle = 2; +} catch ( Exception $e ) { + die( "Критическая ошибка при создании ApiClient: ({$e->getCode()}) " . $e->getMessage() ); +} + +/* + * Пример: Получаем продажи + */ +$sales = $WbApiClient->sales( $dateFrom ); +if ( isset( $sales->is_error ) ) { + echo "\nError: " . implode( '; ', $sales->errors ); +} else { + var_dump( $sales ); +} + +/* + * Пример: Получаем отчет о продажах по реализации + */ +$reportDetailByPeriod = $WbApiClient->reportDetailByPeriod( $dateFrom, $dateTo ); +if ( isset( $reportDetailByPeriod->is_error ) ) { + echo "\nError: " . implode( '; ', $reportDetailByPeriod->errors ); +} else { + var_dump( $reportDetailByPeriod ); +} + +// Можно задать общую дату (dateFrom) через функцию setDateFrom() и затем обращаться к другим функциям, не передавая дату +$WbApiClient->setDateFrom( $dateFrom ); +$sales = $WbApiClient->sales(); +$incomes = $WbApiClient->incomes(); + +``` diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..e010725 --- /dev/null +++ b/composer.json @@ -0,0 +1,57 @@ +{ + "name": "leonid74/wildberries-api-php", + "type": "library", + "description": "Wildberries REST API statistics client library with throttling requests", + "keywords": [ + "Wildberries", + "WB", + "rest", + "api", + "sdk", + "client", + "stat", + "statistics" + ], + "homepage": "https://github.com/leonid74/wildberries-api-php", + "license": "BSD-3-Clause", + "authors": [ + { + "name": "leonid74", + "homepage": "https://github.com/leonid74/", + "role": "Developer" + } + ], + "require": { + "php": ">=7.4", + "ext-curl": "*", + "ext-json": "*", + "josantonius/httpstatuscode": "^1.1" + }, + "require-dev": { + "automattic/phpcs-neutron-standard": "^1.7", + "dealerdirect/phpcodesniffer-composer-installer": "^0.7.1", + "phpunit/phpunit": "^9", + "squizlabs/php_codesniffer": "^3.5" + }, + "autoload": { + "psr-4": { + "Leonid74\\Wildberries\\": "src/" + } + }, + "scripts": { + "post-update-cmd": [ + "@composer dump-autoload" + ], + "check-code": [ + "phpcs -sp src/ tests/" + ], + "tests": [ + "@php vendor/bin/phpunit tests" + ] + }, + "config": { + "process-timeout": 0, + "sort-packages": true, + "optimize-autoloader": true + } +} diff --git a/example/index.php b/example/index.php new file mode 100644 index 0000000..a120cd0 --- /dev/null +++ b/example/index.php @@ -0,0 +1,48 @@ +'; +$dateFrom = '01-01-2022'; +$dateTo = '19-01-2022'; + +try { + $WbApiClient = new WbApiClient( $token ); + $WbApiClient->debugLevel = WbApiClient::DEBUG_URL; + $WbApiClient->throttle = 2; +} catch ( Exception $e ) { + die( "Критическая ошибка при создании ApiClient: ({$e->getCode()}) " . $e->getMessage() ); +} + +$sales = $WbApiClient->sales( $dateFrom ); +if ( isset( $sales->is_error ) ) { + echo "\nError: " . implode( '; ', $sales->errors ); +} else { + var_dump( $sales ); +} + +$reportDetailByPeriod = $WbApiClient->reportDetailByPeriod( $dateFrom, $dateTo ); +if ( isset( $reportDetailByPeriod->is_error ) ) { + echo "\nError: " . implode( '; ', $reportDetailByPeriod->errors ); +} else { + var_dump( $reportDetailByPeriod ); +} + +// You can set a common date (dateFrom) via the setDateFrom() function and then access other functions without passing the date +// Можно задать общую дату (dateFrom) через функцию setDateFrom() и затем обращаться к другим функциям, не передавая дату +$WbApiClient->setDateFrom( $dateFrom ); +$sales = $WbApiClient->sales(); +$incomes = $WbApiClient->incomes(); diff --git a/phpcs.xml b/phpcs.xml new file mode 100644 index 0000000..f94cc20 --- /dev/null +++ b/phpcs.xml @@ -0,0 +1,128 @@ + + + Leonid74 custom coding standard. + + + */.history/* + */.vscode/* + */logs/* + */tests/* + */vendor/* + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/WbApiClient.php b/src/WbApiClient.php new file mode 100644 index 0000000..c6c5259 --- /dev/null +++ b/src/WbApiClient.php @@ -0,0 +1,499 @@ +token = $token; + $this->dateFrom = $dateFrom; + } + + /** + * Get the token + * Получить токен + * + * @return string + */ + public function getToken(): string + { + return $this->token; + } + + /** + * Get the date set using setDateFrom() + * Получить установленную с помощью setDateFrom() дату + * + * @return string + */ + public function getDateFrom(): ?string + { + return $this->dateFrom; + } + + /** + * Set dateFrom + * Установить дату + * + * @return string + */ + public function setDateFrom( string $dateFrom = null ): WbApiClient + { + $this->dateFrom = $dateFrom; + + return $this; + } + + /** + * Form and send request to API service + * Формируем и отправляем запрос к API сервису + * + * @param string $path + * @param string $method + * @param array $data + * + * @return stdClass + */ + protected function sendRequest( string $path, string $method = 'GET', array $data = [] ) + { + if ( empty( $this->token ) ) { + return $this->handleError( 'The Token is not specified' ); + } + + if ( !isset( $data['dateFrom'] ) ) { + return $this->handleError( 'The dateFrom parameter is not specified' ); + } + + $data['dateFrom'] = date( DATE_RFC3339, strtotime( $data['dateFrom'] ) ); + $data['dateTo'] = date( DATE_RFC3339, strtotime( isset( $data['dateTo'] ) ? $data['dateTo'] : 'now' ) ); + $data['key'] = $this->token; + $url = $this->apiUrl . '/' . $path; + $method = strtoupper( $method ); + $headers = [ 'Content-Type: application/json' ]; + + $this->curl = curl_init(); + + switch ( $method ) { + case 'POST': + curl_setopt( $this->curl, CURLOPT_POST, true ); + curl_setopt( $this->curl, CURLOPT_POSTFIELDS, http_build_query( $data ) ); + break; + case 'PUT': + curl_setopt( $this->curl, CURLOPT_CUSTOMREQUEST, 'PUT' ); + curl_setopt( $this->curl, CURLOPT_POSTFIELDS, http_build_query( $data ) ); + break; + case 'DELETE': + curl_setopt( $this->curl, CURLOPT_CUSTOMREQUEST, 'DELETE' ); + curl_setopt( $this->curl, CURLOPT_POSTFIELDS, http_build_query( $data ) ); + break; + default: + if ( !empty( $data ) ) { + $url .= '?' . http_build_query( $data ); + } + } + + curl_setopt( $this->curl, CURLOPT_URL, $url ); + curl_setopt( $this->curl, CURLOPT_SSL_VERIFYPEER, false ); + curl_setopt( $this->curl, CURLOPT_SSL_VERIFYHOST, false ); + curl_setopt( $this->curl, CURLOPT_RETURNTRANSFER, true ); + curl_setopt( $this->curl, CURLOPT_HEADER, true ); + curl_setopt( $this->curl, CURLOPT_CONNECTTIMEOUT, $this->curlConnectTimeout ); + curl_setopt( $this->curl, CURLOPT_TIMEOUT, $this->curlTimeout ); + + $this->requestCounter++; + + // Print the url and headers of the request + // Выводим url и заголовки запроса + $this->debug( "[{$this->requestCounter}] ===> REQUEST {$method} {$url}", self::DEBUG_URL ); + if ( !empty( $headers ) ) { + curl_setopt( $this->curl, CURLOPT_HTTPHEADER, $headers ); + $this->debug( "[{$this->requestCounter}] ===> REQUEST HEADERS:\n" . var_export( $headers, true ), self::DEBUG_HEADERS ); + } + + $response = $this->throttleCurl(); + $deltaTime = sprintf( '%0.4f', microtime( true ) - $this->lastRequestTime ); + + $curlErrors = curl_error( $this->curl ); + $curlInfo = curl_getinfo( $this->curl ); + $ip = $curlInfo['primary_ip']; + $header_size = $curlInfo['header_size']; + $headerCode = $curlInfo['http_code']; + $responseHeaders = trim( substr( $response, 0, $header_size ) ); + $responseBodyRaw = substr( $response, $header_size ); + $responseBody = json_decode( $responseBodyRaw ); + if ( json_last_error() !== JSON_ERROR_NONE ) { + $responseBody = $responseBodyRaw; + } + unset( $response, $responseBodyRaw ); + + curl_close( $this->curl ); + + // Print the headers of the request again + // Выводим еще заголовки запроса + if ( isset( $curlInfo['request_header'] ) ) { + $this->debug( "[{$this->requestCounter}] ===> REQUEST HEADERS:\n" . $curlInfo['request_header'], self::DEBUG_HEADERS ); + } + + // Print the request parameters + // Выводим параметры запроса + $this->debug( "[{$this->requestCounter}] ===> REQUEST PARAMS:\n" . var_export( $data, true ), self::DEBUG_CONTENT ); + + $retval = new stdClass(); + $retval->data = $responseBody; + $retval->http_code = $headerCode; + $retval->headers = $responseHeaders; + $retval->ip = $ip; + $retval->curlErrors = $curlErrors; + $retval->method = $method . ':' . $url; + $retval->timestamp = date( DATE_RFC3339 ); + + // Print the url, headers and the result of the response + // Выводим url, заголовки и результат ответа + $this->debug( "[{$this->requestCounter}] <=== RESPONSE TIME IN {$deltaTime}s (CODE: {$headerCode})", self::DEBUG_URL ); + $this->debug( "[{$this->requestCounter}] <=== RESPONSE HEADERS:\n{$responseHeaders}", self::DEBUG_HEADERS ); + $this->debug( "[{$this->requestCounter}] <=== RESPONSE RESULT:\n" . var_export( ['info' => $curlInfo, 'result' => $this->handleResult( $retval )], true ), self::DEBUG_CONTENT ); + + return $retval; + } + + /** + * Provides trottling of HTTP requests + * Обеспечивает троттлинг HTTP запросов + * + * @return string|false + */ + private function throttleCurl() + { + do { + if ( empty( $this->throttle ) ) { + break; + } + + // Calculate the required delay time before sending the request, microseconds + // Вычисляем необходимое время задержки перед отправкой запроса, микросекунды + $usleep = (int)( 1E6 * ( $this->lastRequestTime + 1 / $this->throttle - microtime( true ) ) ); + if ( $usleep <= 0 ) { + break; + } + + $sleep = sprintf( '%0.4f', $usleep / 1E6 ); + $this->debug( "[{$this->requestCounter}] +++++ THROTTLE REQUEST (" . $this->throttle . "/sec) {$sleep}'s +++++", self::DEBUG_URL ); + usleep( $usleep ); + } while ( false ); + + $this->lastRequestTime = microtime( true ); + + $response = curl_exec( $this->curl ); + + return $response; + } + + /** + * Outputs debugging messages to STDOUT at a given level of debugging information output + * Выводит в STDOUT отладочные сообщения на заданном уровне вывода отладочной информации + * + * @param string + * @param int + * + * @return void + */ + protected function debug( string $message, int $callerLogLevel = 999 ): void + { + if ( $this->debugLevel >= $callerLogLevel ) { + echo $message . PHP_EOL; + } + } + + /** + * Process results + * Обрабатываем результат + * + * @param mixed + * + * @return stdClass + */ + protected function handleResult( $data ) + { + if ( empty( $data->data ) ) { + $data->data = new stdClass(); + } + if ( !in_array( $data->http_code, $this->successStatusCodes ) || isset( $data->data->errors ) ) { + $data->data->is_error = true; + if ( !isset( $data->data->errors ) ) { + $data->data->errors[] = HTTPStatusCode::get( $data->http_code ); + } + if ( !empty( $data->curlErrors ) ) { + $data->data->errors[] = $data->curlErrors; + } + $data->data->http_code = $data->http_code; + $data->data->headers = $data->headers; + $data->data->ip = $data->ip; + $data->data->method = $data->method; + $data->data->timestamp = $data->timestamp; + } + + return $data->data; + } + + /** + * Process errors + * Обрабатываем ошибки + * + * @param string|null + * + * @return stdClass + */ + protected function handleError( ?string $customMessage = null ) + { + $message = new stdClass(); + $message->is_error = true; + if ( null !== $customMessage ) { + $message->message = $customMessage; + } + + return $message; + } + + /** + * API interface implementation + */ + + /** + * Get the incomes + * Получить поставки + * + * @param string + * + * @return stdClass + */ + public function incomes( string $dateFrom = null ) + { + $data = ['dateFrom' => $dateFrom ?? $this->dateFrom ?? null]; + $requestResult = $this->sendRequest( 'incomes', 'GET', $data ); + + return $this->handleResult( $requestResult ); + } + + /** + * Get the stocks + * Получить складскую информацию + * + * @param string + * + * @return stdClass + */ + public function stocks( string $dateFrom = null ) + { + $data = ['dateFrom' => $dateFrom ?? $this->dateFrom ?? null]; + $requestResult = $this->sendRequest( 'stocks', 'GET', $data ); + + return $this->handleResult( $requestResult ); + } + + /** + * Get the orders + * Получить заказы + * + * @param string + * @param int + * + * @return stdClass + */ + public function orders( string $dateFrom = null, int $flag = 0 ) + { + if ( $flag < 0 || $flag > 1 ) { + return $this->handleError( 'The flag value must be 0 or 1' ); + } + + $data = ['dateFrom' => $dateFrom ?? $this->dateFrom ?? null, 'flag' => $flag]; + $requestResult = $this->sendRequest( 'orders', 'GET', $data ); + + return $this->handleResult( $requestResult ); + } + + /** + * Get the sales + * Получить продажи + * + * @param string + * @param int + * + * @return stdClass + */ + public function sales( string $dateFrom = null, int $flag = 0 ) + { + if ( $flag < 0 || $flag > 1 ) { + return $this->handleError( 'The flag value must be 0 or 1' ); + } + + $data = ['dateFrom' => $dateFrom ?? $this->dateFrom ?? null, 'flag' => $flag]; + $requestResult = $this->sendRequest( 'sales', 'GET', $data ); + + return $this->handleResult( $requestResult ); + } + + /** + * Get the sales report details by period + * Получить отчет о продажах по реализации + * + * @param string + * @param string + * @param int + * @param int + * + * @return stdClass + */ + public function reportDetailByPeriod( string $dateFrom = null, string $dateTo = null, int $limit = 100, int $rrdid = 0 ) + { + $data = ['dateFrom' => $dateFrom ?? $this->dateFrom ?? null, 'dateTo' => $dateTo, 'limit' => $limit, 'rrdid' => $rrdid]; + $requestResult = $this->sendRequest( 'reportDetailByPeriod', 'GET', $data ); + + return $this->handleResult( $requestResult ); + } + + /** + * Get the report on excise goods + * Получить отчет по КиЗам + * + * @param string + * + * @return stdClass + */ + public function exciseGoods( string $dateFrom = null ) + { + $data = ['dateFrom' => $dateFrom ?? $this->dateFrom ?? null]; + $requestResult = $this->sendRequest( 'exciseGoods', 'GET', $data ); + + return $this->handleResult( $requestResult ); + } +} diff --git a/src/WbApiInterface.php b/src/WbApiInterface.php new file mode 100644 index 0000000..135baac --- /dev/null +++ b/src/WbApiInterface.php @@ -0,0 +1,59 @@ +