Passed
Branch develop (2fd4b5)
by Alexey
01:37
created

GPlayApps   F

Complexity

Total Complexity 114

Size/Duplication

Total Lines 1152
Duplicated Lines 0 %

Test Coverage

Coverage 85.31%

Importance

Changes 0
Metric Value
eloc 409
dl 0
loc 1152
ccs 331
cts 388
cp 0.8531
rs 2
c 0
b 0
f 0
wmc 114

35 Methods

Rating   Name   Duplication   Size   Complexity  
A getLocale() 0 3 1
B getAppReviews() 0 36 8
A existsApp() 0 15 2
A setCountry() 0 4 2
A setLocale() 0 4 1
A setCache() 0 6 1
A getUrlListFromAppIds() 0 7 2
A getCountry() 0 3 1
A castToCategoryId() 0 9 3
A getAppInLocales() 0 8 2
A setConcurrency() 0 4 1
A castToDeveloperId() 0 12 4
A getCategoriesInAvailableLocales() 0 3 1
A castToAppId() 0 14 4
A getApp() 0 3 1
A getHttpClient() 0 7 2
A setConnectTimeout() 0 4 1
B saveGoogleImages() 0 61 11
A setTimeout() 0 4 1
B getAppInAvailableLocales() 0 54 10
A search() 0 21 2
A setProxy() 0 4 1
A __construct() 0 7 1
A getSearchSuggestions() 0 25 3
A getDeveloperInfoInLocales() 0 35 5
B getAppsFromClusterPage() 0 48 10
A getDeveloperInfo() 0 25 3
A getCategoriesInLocales() 0 26 4
A getPermissions() 0 20 2
A getCategories() 0 16 2
A getSimilarApps() 0 25 3
A getApps() 0 17 3
A existsApps() 0 18 3
B getAppsByCategory() 0 65 9
A getDeveloperApps() 0 40 4

How to fix   Complexity   

Complex Class

Complex classes like GPlayApps often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use GPlayApps, and based on these observations, apply Extract Interface, too.

1
<?php
2
declare(strict_types=1);
3
4
/**
5
 * @author   Ne-Lexa
6
 * @license  MIT
7
 * @link     https://github.com/Ne-Lexa/google-play-scraper
8
 */
9
10
namespace Nelexa\GPlay;
11
12
use GuzzleHttp\Exception\GuzzleException;
13
use GuzzleHttp\Promise\EachPromise;
14
use GuzzleHttp\Promise\FulfilledPromise;
15
use GuzzleHttp\RequestOptions;
16
use Nelexa\GPlay\Enum\AgeEnum;
17
use Nelexa\GPlay\Enum\CategoryEnum;
18
use Nelexa\GPlay\Enum\CollectionEnum;
19
use Nelexa\GPlay\Enum\PriceEnum;
20
use Nelexa\GPlay\Enum\SortEnum;
21
use Nelexa\GPlay\Exception\GooglePlayException;
22
use Nelexa\GPlay\Http\HttpClient;
23
use Nelexa\GPlay\Model\App;
24
use Nelexa\GPlay\Model\AppDetail;
25
use Nelexa\GPlay\Model\AppId;
26
use Nelexa\GPlay\Model\Category;
27
use Nelexa\GPlay\Model\Developer;
28
use Nelexa\GPlay\Model\GoogleImage;
29
use Nelexa\GPlay\Model\ImageInfo;
30
use Nelexa\GPlay\Model\Permission;
31
use Nelexa\GPlay\Model\Review;
32
use Nelexa\GPlay\Scraper\AppDetailScraper;
33
use Nelexa\GPlay\Scraper\CategoriesScraper;
34
use Nelexa\GPlay\Scraper\CategoryAppsScraper;
35
use Nelexa\GPlay\Scraper\ClusterAppsScraper;
36
use Nelexa\GPlay\Scraper\DeveloperInfoScraper;
37
use Nelexa\GPlay\Scraper\ExistsAppScraper;
38
use Nelexa\GPlay\Scraper\FindDevAppsUrlScraper;
39
use Nelexa\GPlay\Scraper\FindSimilarAppsUrlScraper;
40
use Nelexa\GPlay\Scraper\PermissionScraper;
41
use Nelexa\GPlay\Scraper\PlayStoreUiAppsScraper;
42
use Nelexa\GPlay\Scraper\PlayStoreUiRequest;
43
use Nelexa\GPlay\Scraper\ReviewsScraper;
44
use Nelexa\GPlay\Scraper\SuggestScraper;
45
use Nelexa\GPlay\Util\LocaleHelper;
46
use Psr\Http\Message\ResponseInterface;
47
use Psr\SimpleCache\CacheInterface;
48
49
/**
50
 * Contains methods for extracting information from the Google Play store.
51
 */
52
class GPlayApps
53
{
54
    /** @var string default request locale */
55
    public const DEFAULT_LOCALE = 'en_US';
56
57
    /** @var string default request country */
58
    public const DEFAULT_COUNTRY = 'us';
59
60
    /** @var string Google Play base url */
61
    public const GOOGLE_PLAY_URL = 'https://play.google.com';
62
63
    /** @var string Google Play apps url */
64
    public const GOOGLE_PLAY_APPS_URL = self::GOOGLE_PLAY_URL . '/store/apps';
65
66
    /** @var int Maximum number of search results (Google limit). */
67
    public const MAX_SEARCH_RESULTS = 250;
68
69
    /** @var int Limit for all available results. */
70
    public const UNLIMIT = -1;
71
72
    /** @internal */
73
    public const REQ_PARAM_LOCALE = 'hl';
74
75
    /** @internal */
76
    public const REQ_PARAM_COUNTRY = 'gl';
77
78
    /** @internal */
79
    public const REQ_PARAM_ID = 'id';
80
81
    /** @var int Limit of parallel HTTP requests */
82
    protected $concurrency = 4;
83
84
    /** @var string Locale (language) for HTTP requests to Google Play */
85
    protected $locale;
86
87
    /** @var string Country for HTTP requests to Google Play */
88
    protected $country;
89
90
    /**
91
     * Creates an object to retrieve data about Android applications from the Google Play store.
92
     *
93
     * @param string $locale Locale (language) for HTTP requests to Google Play
94
     *     or {@see \Nelexa\GPlay\GPlayApps::DEFAULT_LOCALE}.
95
     * @param string $country Country for HTTP requests to Google Play
96
     *     or {@see \Nelexa\GPlay\GPlayApps::DEFAULT_COUNTRY}.
97
     *
98
     * @see \Nelexa\GPlay\GPlayApps::DEFAULT_LOCALE Default locale
99
     * @see \Nelexa\GPlay\GPlayApps::DEFAULT_COUNTRY Default country
100
     */
101 43
    public function __construct(
102
        string $locale = self::DEFAULT_LOCALE,
103
        string $country = self::DEFAULT_COUNTRY
104
    ) {
105
        $this
106 43
            ->setLocale($locale)
107 43
            ->setCountry($country);
108 43
    }
109
110
    /**
111
     * Sets caching for HTTP requests.
112
     *
113
     * @param CacheInterface|null $cache PSR-16 Simple Cache instance.
114
     * @param \DateInterval|int|null $cacheTtl TTL cached data.
115
     *
116
     * @return GPlayApps Returns an object of its own class to support the call chain.
117
     */
118 39
    public function setCache(?CacheInterface $cache, $cacheTtl = null): GPlayApps
119
    {
120 39
        $this->getHttpClient()
121 39
            ->setCache($cache)
122 39
            ->setCacheTtl($cacheTtl);
123 39
        return $this;
124
    }
125
126
    /**
127
     * Returns an instance of HTTP client.
128
     *
129
     * @return HttpClient Http Client.
130
     */
131 42
    protected function getHttpClient(): HttpClient
132
    {
133 42
        static $httpClient;
134 42
        if ($httpClient === null) {
135 1
            $httpClient = new HttpClient();
136
        }
137 42
        return $httpClient;
138
    }
139
140
    /**
141
     * Sets the limit of concurrent HTTP requests.
142
     *
143
     * @param int $concurrency Maximum number of concurrent HTTP requests.
144
     *
145
     * @return GPlayApps Returns an object of its own class to support the call chain.
146
     */
147 4
    public function setConcurrency(int $concurrency): GPlayApps
148
    {
149 4
        $this->concurrency = max(1, $concurrency);
150 4
        return $this;
151
    }
152
153
    /**
154
     * Sets proxy for outgoing HTTP requests.
155
     *
156
     * @param string|null $proxy Proxy url, ex. socks5://127.0.0.1:9050 or https://116.90.233.2:47348
157
     *
158
     * @return GPlayApps Returns an object of its own class to support the call chain.
159
     *
160
     * @see https://curl.haxx.se/libcurl/c/CURLOPT_PROXY.html Description of proxy URL formats in CURL.
161
     */
162
    public function setProxy(?string $proxy): GPlayApps
163
    {
164
        $this->getHttpClient()->setProxy($proxy);
165
        return $this;
166
    }
167
168
    /**
169
     * Returns detailed information about the Android application from the Google Play store.
170
     *
171
     * For information, you must specify the application ID (android package name).
172
     * The application ID can be viewed in the Google Play store:
173
     * `https://play.google.com/store/apps/details?id=XXXXXX` , where
174
     * XXXXXX is the application id.
175
     *
176
     * Or it can be found in the APK file.
177
     * ```shell
178
     * aapt dump file.apk | grep package | awk '{print $2}' | sed s/name=//g | sed s/\'//g
179
     * ```
180
     *
181
     * @param string|AppId $appId Application ID (Android package name) as
182
     *     a string or {@see \Nelexa\GPlay\Model\AppId} object.
183
     *
184
     * @return AppDetail Detailed information about the Android application or exception.
185
     *
186
     * @throws GooglePlayException If the application is not exists or other HTTP error.
187
     */
188 8
    public function getApp($appId): AppDetail
189
    {
190 8
        return $this->getApps([$appId])[0];
191
    }
192
193
    /**
194
     * Returns detailed information about many android packages.
195
     * HTTP requests are executed in parallel.
196
     *
197
     * @param string[]|AppId[] $appIds Array of application identifiers.
198
     *
199
     * @return AppDetail[] An array of detailed information for each application.
200
     *     The keys of the returned array matches to the passed array.
201
     *
202
     * @throws GooglePlayException If the application is not exists or other HTTP error.
203
     *
204
     * @see GPlayApps::setConcurrency() To set the limit of parallel requests
205
     */
206 14
    public function getApps(array $appIds): array
207
    {
208 14
        if (empty($appIds)) {
209 1
            return [];
210
        }
211 13
        $urls = $this->getUrlListFromAppIds($appIds);
212
        try {
213 11
            return $this->getHttpClient()->requestAsyncPool(
214 11
                'GET',
215
                $urls,
216
                [
217 11
                    HttpClient::OPTION_HANDLER_RESPONSE => new AppDetailScraper(),
218
                ],
219 11
                $this->concurrency
220
            );
221 2
        } catch (GuzzleException $e) {
222 2
            throw new GooglePlayException($e->getMessage(), $e->getCode(), $e);
223
        }
224
    }
225
226
    /**
227
     * Returns an array of URLs for application identifiers.
228
     *
229
     * @param string[]|AppId[] $appIds Array of application identifiers.
230
     *
231
     * @return string[] Array of URL.
232
     *
233
     * @throws \InvalidArgumentException If application ID is empty.
234
     */
235 14
    protected function getUrlListFromAppIds(array $appIds): array
236
    {
237 14
        $urls = [];
238 14
        foreach ($appIds as $key => $appId) {
239 14
            $urls[$key] = $this->castToAppId($appId)->getFullUrl();
240
        }
241 12
        return $urls;
242
    }
243
244
    /**
245
     * Casts the application identifier to the {@see \Nelexa\GPlay\Model\AppId} type.
246
     *
247
     * @param string|AppId $appId Application ID.
248
     *
249
     * @return AppId Application identifier with an \Nelexa\GPlay\Model\AppId type.
250
     *
251
     * @throws \InvalidArgumentException If the application identifier is null or an invalid parameter is passed.
252
     */
253 23
    protected function castToAppId($appId): AppId
254
    {
255 23
        if ($appId === null) {
256 1
            throw new \InvalidArgumentException('Application ID is null');
257
        }
258 22
        if (is_string($appId)) {
259 16
            return new AppId($appId, $this->locale, $this->country);
260
        }
261 8
        if ($appId instanceof AppId) {
0 ignored issues
show
introduced by
$appId is always a sub-type of Nelexa\GPlay\Model\AppId.
Loading history...
262 8
            return $appId;
263
        }
264
        throw new \InvalidArgumentException(sprintf(
265
            'The expected type for the $appId parameter is a string or %s.',
266
            AppId::class
267
        ));
268
    }
269
270
    /**
271
     * Returns detailed information about an application from the Google Play store
272
     * for an array of locales.
273
     * HTTP requests are executed in parallel.
274
     *
275
     * @param string|AppId $appId Application ID (Android package name) as
276
     *     a string or {@see \Nelexa\GPlay\Model\AppId} object.
277
     * @param string[] $locales Array of locales for which to load information.
278
     *
279
     * @return AppDetail[] Array of application detailed application by locale.
280
     *     The array key is the locale.
281
     *
282
     * @throws GooglePlayException If the application is not exists or other HTTP error.
283
     *
284
     * @see GPlayApps::setConcurrency() To set the limit of parallel requests.
285
     */
286 3
    public function getAppInLocales($appId, array $locales): array
287
    {
288 3
        $appId = $this->castToAppId($appId);
289 2
        $requests = [];
290 2
        foreach ($locales as $locale) {
291 2
            $requests[$locale] = new AppId($appId->getId(), $locale, $appId->getCountry());
292
        }
293 2
        return $this->getApps($requests);
294
    }
295
296
    /**
297
     * Returns detailed information about the application in all
298
     * available locales.
299
     *
300
     * Information is returned only for the description loaded by the developer.
301
     * All locales with automated translation from Google Translate will be ignored.
302
     * HTTP requests are executed in parallel.
303
     *
304
     * @param string|AppId $appId Application ID (Android package name) as
305
     *     a string or {@see \Nelexa\GPlay\Model\AppId} object.
306
     *
307
     * @return AppDetail[] An array with detailed information about the application
308
     *     on all available locales. The array key is the locale.
309
     *
310
     * @throws GooglePlayException If the application is not exists or other HTTP error.
311
     *
312
     * @see \Nelexa\GPlay\GPlayApps::setConcurrency() To set the limit of parallel requests.
313
     */
314 2
    public function getAppInAvailableLocales($appId): array
315
    {
316 2
        $list = $this->getAppInLocales($appId, LocaleHelper::SUPPORTED_LOCALES);
317
318 1
        $preferredLocale = self::DEFAULT_LOCALE;
319 1
        foreach ($list as $app) {
320 1
            if ($app->isAutoTranslatedDescription()) {
321 1
                $preferredLocale = $app->getTranslatedFromLocale();
322 1
                break;
323
            }
324
        }
325
326
        /**
327
         * @var AppDetail[] $list
328
         */
329
        $list = array_filter($list, static function (AppDetail $app) {
330 1
            return !$app->isAutoTranslatedDescription();
331 1
        });
332
333 1
        $preferredApp = $list[$preferredLocale];
334
        $list = array_filter($list, static function (AppDetail $app, string $locale) use ($preferredApp, $list) {
335
            // deletes locales in which there is no translation added, but automatic translation by Google Translate is used.
336 1
            if ($preferredApp->getLocale() === $locale || !$preferredApp->equals($app)) {
337 1
                if (($pos = strpos($locale, '_')) !== false) {
338 1
                    $rootLang = substr($locale, 0, $pos);
339 1
                    $rootLangLocale = LocaleHelper::getNormalizeLocale($rootLang);
340
                    if (
341 1
                        $rootLangLocale !== $locale &&
342 1
                        isset($list[$rootLangLocale]) &&
343 1
                        $list[$rootLangLocale]->equals($app)
344
                    ) {
345
                        // delete duplicate data,
346
                        // for example, delete en_CA, en_IN, en_GB, en_ZA, if there is en_US and they are equals.
347 1
                        return false;
348
                    }
349
                }
350 1
                return true;
351
            }
352
            return false;
353 1
        }, ARRAY_FILTER_USE_BOTH);
354
355
        // sorting array keys; the first key is the preferred locale
356 1
        uksort(
357 1
            $list,
358
            static function (
359
                /** @noinspection PhpUnusedParameterInspection */
360
                string $a,
361
                string $b
362
            ) use ($preferredLocale) {
363 1
                return $b === $preferredLocale ? 1 : 0;
364 1
            }
365
        );
366
367 1
        return $list;
368
    }
369
370
    /**
371
     * Checks if the specified application exists in the Google Play Store.
372
     *
373
     * @param string|AppId $appId Application ID (Android package name) as
374
     *     a string or {@see \Nelexa\GPlay\Model\AppId} object.
375
     *
376
     * @return bool Returns `true` if the application exists, or `false` if not.
377
     */
378 5
    public function existsApp($appId): bool
379
    {
380 5
        $appId = $this->castToAppId($appId);
381
382
        try {
383 5
            return (bool)$this->getHttpClient()->request(
384 5
                'HEAD',
385 5
                $appId->getFullUrl(),
386
                [
387 5
                    RequestOptions::HTTP_ERRORS => false,
388 5
                    HttpClient::OPTION_HANDLER_RESPONSE => new ExistsAppScraper(),
389
                ]
390
            );
391
        } catch (GuzzleException $e) {
392
            return false;
393
        }
394
    }
395
396
    /**
397
     * Checks if the specified applications exist in the Google Play store.
398
     * HTTP requests are executed in parallel.
399
     *
400
     * @param string[]|AppId[] $appIds Array of application identifiers.
401
     *     The keys of the returned array correspond to the transferred array.
402
     *
403
     * @return bool[] An array of information about the existence of each
404
     *     application in the store Google Play. The keys of the returned
405
     *     array matches to the passed array.
406
     *
407
     * @throws GooglePlayException If an HTTP error other than 404 is received.
408
     *
409
     * @see GPlayApps::setConcurrency() To set the limit of parallel requests.
410
     */
411 1
    public function existsApps(array $appIds): array
412
    {
413 1
        if (empty($appIds)) {
414
            return [];
415
        }
416 1
        $urls = $this->getUrlListFromAppIds($appIds);
417
        try {
418 1
            return $this->getHttpClient()->requestAsyncPool(
419 1
                'HEAD',
420
                $urls,
421
                [
422 1
                    RequestOptions::HTTP_ERRORS => false,
423 1
                    HttpClient::OPTION_HANDLER_RESPONSE => new ExistsAppScraper(),
424
                ],
425 1
                $this->concurrency
426
            );
427
        } catch (GuzzleException $e) {
428
            throw new GooglePlayException($e->getMessage(), $e->getCode(), $e);
429
        }
430
    }
431
432
    /**
433
     * Returns reviews of the Android app in the Google Play Store.
434
     * Getting a lot of reviews can take a lot of time.
435
     *
436
     * @param string|AppId $appId Application ID (Android package name) as
437
     *     a string or {@see \Nelexa\GPlay\Model\AppId} object.
438
     * @param int $limit Maximum number of reviews. To extract all
439
     *     reviews, use {@see \Nelexa\GPlay\GPlayApps::UNLIMIT}.
440
     * @param SortEnum|null $sort Sort reviews of the application.
441
     *     If null, then sort by the newest reviews.
442
     *
443
     * @return Review[] App reviews.
444
     *
445
     * @throws GooglePlayException if the application is not exists or other HTTP error.
446
     *
447
     * @see SortEnum::NEWEST() Contains all valid values ​​for the "sort" parameter.
448
     * @see \Nelexa\GPlay\GPlayApps::UNLIMIT Limit for all results.
449
     */
450 1
    public function getAppReviews($appId, int $limit = 100, ?SortEnum $sort = null): array
451
    {
452 1
        $appId = $this->castToAppId($appId);
453 1
        $sort = $sort ?? SortEnum::NEWEST();
454
455 1
        $allCount = 0;
456 1
        $token = null;
457 1
        $allReviews = [];
458
459 1
        $cacheTtl = $sort === SortEnum::NEWEST() ?
460 1
            \DateInterval::createFromDateString('5 min') :
461 1
            \DateInterval::createFromDateString('1 hour');
462
463
        try {
464
            do {
465 1
                $count = $limit === self::UNLIMIT ?
466
                    PlayStoreUiRequest::LIMIT_REVIEW_ON_PAGE :
467 1
                    min(PlayStoreUiRequest::LIMIT_REVIEW_ON_PAGE, max($limit - $allCount, 1));
468
469 1
                $request = PlayStoreUiRequest::getReviewsRequest($appId, $count, $sort, $token);
470
471 1
                [$reviews, $token] = $this->getHttpClient()->send(
472 1
                    $request,
473
                    [
474 1
                        HttpClient::OPTION_CACHE_TTL => $cacheTtl,
475 1
                        HttpClient::OPTION_HANDLER_RESPONSE => new ReviewsScraper($appId),
476
                    ]
477
                );
478 1
                $allCount += count($reviews);
479 1
                $allReviews[] = $reviews;
480 1
            } while ($token !== null && ($limit === self::UNLIMIT || $allCount < $limit));
481
        } catch (GuzzleException $e) {
482
            throw new GooglePlayException($e->getMessage(), $e->getCode(), $e);
483
        }
484
485 1
        return empty($allReviews) ? $allReviews : array_merge(...$allReviews);
486
    }
487
488
    /**
489
     * Returns an array of similar applications with basic information about
490
     * them in the Google Play store.
491
     *
492
     * @param string|AppId $appId Application ID (Android package name) as
493
     *     a string or {@see \Nelexa\GPlay\Model\AppId} object.
494
     * @param int $limit The maximum number of similar applications. To extract all
495
     *     similar applications, use {@see \Nelexa\GPlay\GPlayApps::UNLIMIT}.
496
     *
497
     * @return App[] An array of applications with basic information about them.
498
     *
499
     * @throws GooglePlayException If the application is not exists or other HTTP error.
500
     *
501
     * @see GPlayApps::UNLIMIT Limit for all results.
502
     */
503 1
    public function getSimilarApps($appId, int $limit = 50): array
504
    {
505 1
        $appId = $this->castToAppId($appId);
506
        try {
507
            /**
508
             * @var string|null $similarAppsUrl
509
             */
510 1
            $similarAppsUrl = $this->getHttpClient()->request(
511 1
                'GET',
512 1
                $appId->getFullUrl(),
513
                [
514 1
                    HttpClient::OPTION_HANDLER_RESPONSE => new FindSimilarAppsUrlScraper($appId),
515
                ]
516
            );
517 1
            if ($similarAppsUrl === null) {
518
                return [];
519
            }
520 1
            return $this->getAppsFromClusterPage(
521 1
                $similarAppsUrl,
522 1
                $appId->getLocale(),
523 1
                $appId->getCountry(),
524
                $limit
525
            );
526
        } catch (GuzzleException $e) {
527
            throw new GooglePlayException($e->getMessage(), $e->getCode(), $e);
528
        }
529
    }
530
531
    /**
532
     * Returns a list of applications with basic information.
533
     *
534
     * @param string $clusterPageUrl Cluster page URL.
535
     * @param string $locale Locale.
536
     * @param string $country Country.
537
     * @param int $limit Maximum number of applications. To extract all
538
     *     applications, use {@see \Nelexa\GPlay\GPlayApps::UNLIMIT}.
539
     *
540
     * @return App[] Array of applications with basic information about them.
541
     *
542
     * @throws GooglePlayException If the application is not exists or other HTTP error.
543
     *
544
     * @see GPlayApps::UNLIMIT Limit for all results.
545
     */
546 3
    protected function getAppsFromClusterPage(
547
        string $clusterPageUrl,
548
        string $locale,
549
        string $country,
550
        int $limit
551
    ): array {
552 3
        if ($limit < self::UNLIMIT || $limit === 0) {
553
            throw new \InvalidArgumentException(sprintf('Invalid limit: %d', $limit));
554
        }
555
        try {
556 3
            [$apps, $token] = $this->getHttpClient()->request(
557 3
                'GET',
558
                $clusterPageUrl,
559
                [
560 3
                    HttpClient::OPTION_HANDLER_RESPONSE => new ClusterAppsScraper(),
561
                ]
562
            );
563
564 3
            $allCount = count($apps);
565 3
            $allApps = [$apps];
566
567 3
            while ($token !== null && ($limit === self::UNLIMIT || $allCount < $limit)) {
568 3
                $count = $limit === self::UNLIMIT ?
569 2
                    PlayStoreUiRequest::LIMIT_APPS_ON_PAGE :
570 3
                    min(PlayStoreUiRequest::LIMIT_APPS_ON_PAGE, max($limit - $allCount, 1));
571
572 3
                $request = PlayStoreUiRequest::getAppsRequest($locale, $country, $count, $token);
573
574 3
                [$apps, $token] = $this->getHttpClient()->send(
575 3
                    $request,
576
                    [
577 3
                        HttpClient::OPTION_HANDLER_RESPONSE => new PlayStoreUiAppsScraper(),
578
                    ]
579
                );
580 3
                $allCount += count($apps);
581 3
                $allApps[] = $apps;
582
            }
583
        } catch (GuzzleException $e) {
584
            throw new GooglePlayException($e->getMessage(), $e->getCode(), $e);
585
        }
586 3
        if (empty($allApps)) {
587
            return $allApps;
588
        }
589 3
        $allApps = array_merge(...$allApps);
590 3
        if ($limit !== self::UNLIMIT) {
591 1
            $allApps = array_slice($allApps, 0, $limit);
592
        }
593 3
        return $allApps;
594
    }
595
596
    /**
597
     * Returns a list of permissions for the application.
598
     *
599
     * @param string|AppId $appId Application ID (Android package name) as
600
     *     a string or {@see \Nelexa\GPlay\Model\AppId} object.
601
     *
602
     * @return Permission[] Array of permissions for the application.
603
     *
604
     * @throws GooglePlayException If the application is not exists or other HTTP error.
605
     */
606 1
    public function getPermissions($appId): array
607
    {
608 1
        $appId = $this->castToAppId($appId);
609
610 1
        $url = self::GOOGLE_PLAY_URL . '/store/xhr/getdoc?authuser=0';
611
        try {
612 1
            return $this->getHttpClient()->request(
613 1
                'POST',
614
                $url,
615
                [
616 1
                    RequestOptions::FORM_PARAMS => [
617 1
                        'ids' => $appId->getId(),
618 1
                        self::REQ_PARAM_LOCALE => $appId->getLocale(),
619 1
                        'xhr' => 1,
620
                    ],
621 1
                    HttpClient::OPTION_HANDLER_RESPONSE => new PermissionScraper(),
622
                ]
623
            );
624
        } catch (GuzzleException $e) {
625
            throw new GooglePlayException($e->getMessage(), $e->getCode(), $e);
626
        }
627
    }
628
629
    /**
630
     * Returns an array of application categories from the Google Play store.
631
     *
632
     * @return Category[] Array of application categories.
633
     *
634
     * @throws GooglePlayException If HTTP error is received.
635
     */
636 1
    public function getCategories(): array
637
    {
638 1
        $url = self::GOOGLE_PLAY_APPS_URL;
639
        try {
640 1
            return $this->getHttpClient()->request(
641 1
                'GET',
642
                $url,
643
                [
644 1
                    RequestOptions::QUERY => [
645 1
                        self::REQ_PARAM_LOCALE => $this->locale,
646
                    ],
647 1
                    HttpClient::OPTION_HANDLER_RESPONSE => new CategoriesScraper(),
648
                ]
649
            );
650
        } catch (GuzzleException $e) {
651
            throw new GooglePlayException($e->getMessage(), $e->getCode(), $e);
652
        }
653
    }
654
655
    /**
656
     * Returns an array of application categories from the Google Play store for the locale array.
657
     * HTTP requests are executed in parallel.
658
     *
659
     * @param string[] $locales Array of locales.
660
     *
661
     * @return Category[][] Array of application categories by locale.
662
     *
663
     * @throws GooglePlayException If HTTP error is received.
664
     *
665
     * @see GPlayApps::setConcurrency() To set the limit of parallel requests.
666
     */
667 2
    public function getCategoriesInLocales(array $locales): array
668
    {
669 2
        if (empty($locales)) {
670
            return [];
671
        }
672 2
        $locales = LocaleHelper::getNormalizeLocales($locales);
673
674 2
        $urls = [];
675 2
        $url = self::GOOGLE_PLAY_APPS_URL;
676 2
        foreach ($locales as $locale) {
677 2
            $urls[$locale] = $url . '?' . http_build_query([
678 2
                    self::REQ_PARAM_LOCALE => $locale,
679
                ]);
680
        }
681
682
        try {
683 2
            return $this->getHttpClient()->requestAsyncPool(
684 2
                'GET',
685
                $urls,
686
                [
687 2
                    HttpClient::OPTION_HANDLER_RESPONSE => new CategoriesScraper(),
688
                ],
689 2
                $this->concurrency
690
            );
691
        } catch (GuzzleException $e) {
692
            throw new GooglePlayException($e->getMessage(), $e->getCode(), $e);
693
        }
694
    }
695
696
    /**
697
     * Returns an array of categories from the Google Play store for all available locales.
698
     *
699
     * @return Category[][] Array of application categories by locale.
700
     *
701
     * @throws GooglePlayException If HTTP error is received.
702
     *
703
     * @see GPlayApps::setConcurrency() To set the limit of parallel requests.
704
     */
705 1
    public function getCategoriesInAvailableLocales(): array
706
    {
707 1
        return $this->getCategoriesInLocales(LocaleHelper::SUPPORTED_LOCALES);
708
    }
709
710
    /**
711
     * Returns information about the developer: name, icon, cover, description and website address.
712
     *
713
     * @param string|Developer|App $developerId Developer id as
714
     *     string, {@see \Nelexa\GPlay\Model\Developer} or {@see \Nelexa\GPlay\Model\App} object.
715
     *
716
     * @return Developer Information about the application developer.
717
     *
718
     * @throws GooglePlayException If HTTP error is received.
719
     *
720
     * @see GPlayApps::getDeveloperInfoInLocales() Returns information about the developer for
721
     *     the locale array.
722
     */
723 5
    public function getDeveloperInfo($developerId): Developer
724
    {
725 5
        $developerId = $this->castToDeveloperId($developerId);
726 5
        if (!is_numeric($developerId)) {
727 3
            throw new GooglePlayException(sprintf(
728 3
                'Developer "%s" does not have a personalized page on Google Play.',
729
                $developerId
730
            ));
731
        }
732
733 2
        $url = self::GOOGLE_PLAY_APPS_URL . '/dev';
734
        try {
735 2
            return $this->getHttpClient()->request(
736 2
                'GET',
737
                $url,
738
                [
739 2
                    RequestOptions::QUERY => [
740 2
                        self::REQ_PARAM_ID => $developerId,
741 2
                        self::REQ_PARAM_LOCALE => $this->locale,
742
                    ],
743 2
                    HttpClient::OPTION_HANDLER_RESPONSE => new DeveloperInfoScraper(),
744
                ]
745
            );
746 1
        } catch (GuzzleException $e) {
747 1
            throw new GooglePlayException($e->getMessage(), $e->getCode(), $e);
748
        }
749
    }
750
751
    /**
752
     * @param string|int|Developer|App|AppDetail $developerId
753
     * @return string
754
     */
755 7
    private function castToDeveloperId($developerId): string
756
    {
757 7
        if ($developerId instanceof App) {
758 1
            return $developerId->getDeveloper()->getId();
759
        }
760 6
        if ($developerId instanceof Developer) {
761 1
            return $developerId->getId();
762
        }
763 5
        if (is_int($developerId)) {
764 2
            return (string)$developerId;
765
        }
766 3
        return $developerId;
767
    }
768
769
    /**
770
     * Returns information about the developer for the locale array.
771
     *
772
     * @param string|Developer|App $developerId Developer id as
773
     *     string, {@see \Nelexa\GPlay\Model\Developer} or {@see \Nelexa\GPlay\Model\App} object.
774
     *
775
     * @param string[] $locales Array of locales
776
     *
777
     * @return Developer[] An array with information about the application developer
778
     *     for each requested locale.
779
     *
780
     * @throws GooglePlayException If HTTP error is received.
781
     *
782
     * @see GPlayApps::setConcurrency() To set the limit of parallel requests.
783
     * @see GPlayApps::getDeveloperInfo() Returns information about the developer: name,
784
     *     icon, cover, description and website address.
785
     */
786 1
    public function getDeveloperInfoInLocales($developerId, array $locales = []): array
787
    {
788 1
        if (empty($locales)) {
789
            return [];
790
        }
791 1
        $locales = LocaleHelper::getNormalizeLocales($locales);
792
793 1
        $id = $this->castToDeveloperId($developerId);
794 1
        if (!is_numeric($id)) {
795
            throw new GooglePlayException(sprintf(
796
                'Developer "%s" does not have a personalized page on Google Play.',
797
                $id
798
            ));
799
        }
800
801 1
        $urls = [];
802 1
        $url = self::GOOGLE_PLAY_APPS_URL . '/dev';
803 1
        foreach ($locales as $locale) {
804 1
            $urls[$locale] = $url . '?' . http_build_query([
805 1
                    self::REQ_PARAM_ID => $id,
806 1
                    self::REQ_PARAM_LOCALE => $locale,
807
                ]);
808
        }
809
810
        try {
811 1
            return $this->getHttpClient()->requestAsyncPool(
812 1
                'GET',
813
                $urls,
814
                [
815 1
                    HttpClient::OPTION_HANDLER_RESPONSE => new DeveloperInfoScraper(),
816
                ],
817 1
                $this->concurrency
818
            );
819
        } catch (GuzzleException $e) {
820
            throw new GooglePlayException($e->getMessage(), $e->getCode(), $e);
821
        }
822
    }
823
824
    /**
825
     * Returns an array of developer apps with basic information about them
826
     *     from the Google Play store.
827
     *
828
     * @param string|Developer|App $developerId Developer id as
829
     *     string, {@see \Nelexa\GPlay\Model\Developer} or {@see \Nelexa\GPlay\Model\App} object.
830
     *
831
     * @return App[] Array of applications with basic information about them.
832
     *
833
     * @throws GooglePlayException If HTTP error is received.
834
     */
835 1
    public function getDeveloperApps($developerId): array
836
    {
837 1
        $developerId = $this->castToDeveloperId($developerId);
838
839
        $query = [
840 1
            self::REQ_PARAM_ID => $developerId,
841 1
            self::REQ_PARAM_LOCALE => $this->locale,
842 1
            self::REQ_PARAM_COUNTRY => $this->country,
843
        ];
844
845 1
        if (is_numeric($developerId)) {
846 1
            $developerUrl = self::GOOGLE_PLAY_APPS_URL . '/dev?' . http_build_query($query);
847
            try {
848
                /**
849
                 * @var string|null $developerUrl
850
                 */
851 1
                $developerUrl = $this->getHttpClient()->request(
852 1
                    'GET',
853
                    $developerUrl,
854
                    [
855 1
                        HttpClient::OPTION_HANDLER_RESPONSE => new FindDevAppsUrlScraper(),
856
                    ]
857
                );
858 1
                if ($developerUrl === null) {
859
                    return [];
860
                }
861 1
                $developerUrl .= '&' . self::REQ_PARAM_LOCALE . '=' . urlencode($this->locale) .
862 1
                    '&' . self::REQ_PARAM_COUNTRY . '=' . urlencode($this->country);
863
            } catch (GuzzleException $e) {
864 1
                throw new GooglePlayException($e->getMessage(), $e->getCode(), $e);
865
            }
866
        } else {
867 1
            $developerUrl = self::GOOGLE_PLAY_APPS_URL . '/developer?' . http_build_query($query);
868
        }
869
870 1
        return $this->getAppsFromClusterPage(
871 1
            $developerUrl,
872 1
            $this->locale,
873 1
            $this->country,
874 1
            self::UNLIMIT
875
        );
876
    }
877
878
    /**
879
     * Returns the Google Play search suggests.
880
     *
881
     * @param string $query Search query.
882
     *
883
     * @return string[] Array containing search suggestions.
884
     *
885
     * @throws GooglePlayException If HTTP error is received.
886
     */
887 1
    public function getSearchSuggestions(string $query): array
888
    {
889 1
        $query = trim($query);
890 1
        if ($query === '') {
891
            return [];
892
        }
893
894 1
        $url = 'https://market.android.com/suggest/SuggRequest';
895
        try {
896 1
            return $this->getHttpClient()->request(
897 1
                'GET',
898
                $url,
899
                [
900 1
                    RequestOptions::QUERY => [
901 1
                        'json' => 1,
902 1
                        'c' => 3,
903 1
                        'query' => $query,
904 1
                        self::REQ_PARAM_LOCALE => $this->locale,
905 1
                        self::REQ_PARAM_COUNTRY => $this->country,
906
                    ],
907 1
                    HttpClient:: OPTION_HANDLER_RESPONSE => new SuggestScraper(),
908
                ]
909
            );
910
        } catch (GuzzleException $e) {
911
            throw new GooglePlayException($e->getMessage(), $e->getCode(), $e);
912
        }
913
    }
914
915
    /**
916
     * Returns a list of applications from the Google Play store for a search query.
917
     *
918
     * @param string $query Search query.
919
     * @param int $limit The limit on the number of search results.
920
     * @param PriceEnum|null $price Price category or `null`.
921
     *
922
     * @return App[] Array of applications with basic information about them.
923
     *
924
     * @throws GooglePlayException If HTTP error is received.
925
     *
926
     * @see PriceEnum Contains all valid values ​​for the "price" parameter.
927
     * @see GPlayApps::UNLIMIT Limit for all available results.
928
     * @see GPlayApps::MAX_SEARCH_RESULTS Maximum number of search results (Google limit).
929
     */
930 1
    public function search(
931
        string $query,
932
        int $limit = 50,
933
        ?PriceEnum $price = null
934
    ): array {
935 1
        $query = trim($query);
936 1
        if (empty($query)) {
937
            throw new \InvalidArgumentException('Search query missing');
938
        }
939 1
        $price = $price ?? PriceEnum::ALL();
940
941
        $params = [
942 1
            'c' => 'apps',
943 1
            'q' => $query,
944 1
            'hl' => $this->locale,
945 1
            'gl' => $this->country,
946 1
            'price' => $price->value(),
947
        ];
948 1
        $clusterPageUrl = self::GOOGLE_PLAY_URL . '/store/search?' . http_build_query($params);
949
950 1
        return $this->getAppsFromClusterPage($clusterPageUrl, $this->locale, $this->country, $limit);
951
    }
952
953
    /**
954
     * Returns a list of applications with basic information from the category and
955
     * collection of the Google Play store.
956
     *
957
     * @param string|Category|CategoryEnum|null $category Application category as
958
     *     string, {@see \Nelexa\GPlay\Model\Category}, {@see \Nelexa\GPlay\Enum\CategoryEnum}
959
     *     or `null`.
960
     * @param CollectionEnum|string $collection Application collection.
961
     * @param int $limit The limit on the number of results.
962
     * @param AgeEnum|null $age Age restrictions or `null`.
963
     *
964
     * @return App[] Array of applications with basic information about them.
965
     *
966
     * @throws GooglePlayException If HTTP error is received.
967
     *
968
     * @see CategoryEnum Contains all valid application category values.
969
     * @see CollectionEnum Contains all valid application collection values.
970
     * @see AgeEnum Contains all valid values ​​for the age parameter.
971
     * @see GPlayApps::getCategories() Returns an array of application categories from the Google Play store.
972
     */
973 1
    public function getAppsByCategory(
974
        $category,
975
        $collection,
976
        int $limit = 60,
977
        ?AgeEnum $age = null
978
    ): array {
979 1
        $maxOffset = 500;
980 1
        $limitOnPage = 60;
981 1
        $maxResults = $maxOffset + $limitOnPage;
982
983 1
        $limit = $limit === self::UNLIMIT ? $maxResults : min($maxResults, max(1, $limit));
984 1
        $collection = (string)$collection;
985
986 1
        $url = self::GOOGLE_PLAY_APPS_URL . '';
987 1
        if ($category !== null) {
988 1
            $url .= '/category/' . $this->castToCategoryId($category);
989
        }
990 1
        $url .= '/collection/' . $collection;
991
992 1
        $offset = 0;
993
994
        $queryParams = [
995 1
            self::REQ_PARAM_LOCALE => $this->locale,
996 1
            self::REQ_PARAM_COUNTRY => $this->country,
997
        ];
998 1
        if ($age !== null) {
999 1
            $queryParams['age'] = $age->value();
1000
        }
1001
1002 1
        $results = [];
1003 1
        $countResults = 0;
1004 1
        $slice = 0;
1005
        try {
1006
            do {
1007 1
                if ($offset > $maxOffset) {
1008
                    $slice = $offset - $maxOffset;
1009
                    $offset = $maxOffset;
1010
                }
1011 1
                $queryParams['num'] = min($limit - $offset + $slice, $limitOnPage);
1012
1013 1
                $result = $this->getHttpClient()->request(
1014 1
                    'POST',
1015
                    $url,
1016
                    [
1017 1
                        RequestOptions::QUERY => $queryParams,
1018 1
                        RequestOptions::FORM_PARAMS => [
1019 1
                            'start' => $offset,
1020
                        ],
1021 1
                        HttpClient::OPTION_HANDLER_RESPONSE => new CategoryAppsScraper(),
1022
                    ]
1023
                );
1024 1
                if ($slice > 0) {
1025
                    $result = array_slice($result, $slice);
1026
                }
1027 1
                $countResult = count($result);
1028 1
                $countResults += $countResult;
1029 1
                $results[] = $result;
1030 1
                $offset += $countResult;
1031 1
            } while ($countResult === $limitOnPage && $countResults < $limit);
1032
        } catch (GuzzleException $e) {
1033
            throw new GooglePlayException($e->getMessage(), $e->getCode(), $e);
1034
        }
1035 1
        $results = array_merge(...$results);
1036 1
        $results = array_slice($results, 0, $limit);
1037 1
        return $results;
1038
    }
1039
1040
    /**
1041
     * @param Category|CategoryEnum|string $category
1042
     * @return string
1043
     */
1044 1
    private function castToCategoryId($category): string
1045
    {
1046 1
        if ($category instanceof CategoryEnum) {
1047 1
            return $category->value();
1048
        }
1049
        if ($category instanceof Category) {
1050
            return $category->getId();
1051
        }
1052
        return (string)$category;
1053
    }
1054
1055
    /**
1056
     * Asynchronously saves images from googleusercontent.com and similar URLs to disk.
1057
     *
1058
     * Before use, you can set the parameters of the width-height of images.
1059
     *
1060
     * @param GoogleImage[] $images Array of {@see \Nelexa\GPlay\Model\GoogleImage} objects.
1061
     * @param callable $destPathCallback The function to which the {@see \Nelexa\GPlay\Model\GoogleImage}
1062
     *     object is passed and you must return the full output. path to save this file. File
1063
     *     extension can be omitted. It will be automatically installed.
1064
     * @param bool $overwrite Overwrite files if exists.
1065
     *
1066
     * @return ImageInfo[] Returns an array with information about saved images.
1067
     *
1068
     * @see GoogleImage Contains a link to the image, allows you to customize its size and download it.
1069
     * @see ImageInfo Contains information about the image.
1070
     */
1071 2
    public function saveGoogleImages(
1072
        array $images,
1073
        callable $destPathCallback,
1074
        bool $overwrite = false
1075
    ): array {
1076 2
        $mapping = [];
1077 2
        foreach ($images as $image) {
1078 2
            if (!$image instanceof GoogleImage) {
1079
                throw new \InvalidArgumentException('An array of ' . GoogleImage::class . ' objects is expected.');
1080
            }
1081 2
            $destPath = $destPathCallback($image);
1082 2
            $mapping[$destPath] = $image->getUrl();
1083
        }
1084
1085 2
        $httpClient = $this->getHttpClient();
1086
        $promises = (static function () use ($mapping, $overwrite, $httpClient) {
1087 2
            foreach ($mapping as $destPath => $url) {
1088 2
                if (!$overwrite && is_file($destPath)) {
1089
                    yield $destPath => new FulfilledPromise($url);
1090
                } else {
1091 2
                    $dir = dirname($destPath);
1092 2
                    if (!is_dir($dir) && !mkdir($dir, 0755, true) && !is_dir($dir)) {
1093
                        throw new \RuntimeException(sprintf('Directory "%s" was not created', $dir));
1094
                    }
1095
                    yield $destPath => $httpClient
1096 2
                        ->requestAsync('GET', $url, [
1097 2
                            RequestOptions::COOKIES => null,
1098 2
                            RequestOptions::SINK => $destPath,
1099 2
                            RequestOptions::HTTP_ERRORS => true,
1100
                        ])
1101
                        ->then(static function (
1102
                            /** @noinspection PhpUnusedParameterInspection */
1103
                            ResponseInterface $response
1104
                        ) use ($url) {
1105 1
                            return $url;
1106 2
                        });
1107
                }
1108
            }
1109 2
        })();
1110
1111
        /**
1112
         * @var ImageInfo[] $imageInfoList
1113
         */
1114 2
        $imageInfoList = [];
1115 2
        (new EachPromise($promises, [
1116 2
            'concurrency' => $this->concurrency,
1117
            'fulfilled' => static function (string $url, string $destPath) use (&$imageInfoList) {
1118 1
                $imageInfoList[] = new ImageInfo($url, $destPath);
1119 2
            },
1120
            'rejected' => static function (\Throwable $reason, string $key) use ($mapping) {
1121 1
                $exceptionUrl = $mapping[$key];
1122 1
                foreach ($mapping as $destPath => $url) {
1123 1
                    if (is_file($destPath)) {
1124 1
                        unlink($destPath);
1125
                    }
1126
                }
1127 1
                throw (new GooglePlayException($reason->getMessage(), $reason->getCode(), $reason))->setUrl($exceptionUrl);
1128 2
            },
1129 2
        ]))->promise()->wait();
1130
1131 1
        return $imageInfoList;
1132
    }
1133
1134
    /**
1135
     * Returns the locale (language) of the requests.
1136
     *
1137
     * @return string Locale (language) for HTTP requests to Google Play.
1138
     */
1139 5
    public function getLocale(): string
1140
    {
1141 5
        return $this->locale;
1142
    }
1143
1144
    /**
1145
     * Sets the locale (language) of requests.
1146
     *
1147
     * @param string $locale Locale (language) for HTTP requests to Google Play.
1148
     *
1149
     * @return GPlayApps Returns an object of its own class to support the call chain.
1150
     */
1151 43
    public function setLocale(string $locale): GPlayApps
1152
    {
1153 43
        $this->locale = LocaleHelper::getNormalizeLocale($locale);
1154 43
        return $this;
1155
    }
1156
1157
    /**
1158
     * Returns the country of the requests.
1159
     *
1160
     * @return string Country for HTTP requests to Google Play.
1161
     */
1162 5
    public function getCountry(): string
1163
    {
1164 5
        return $this->country;
1165
    }
1166
1167
    /**
1168
     * Sets the country of requests.
1169
     *
1170
     * @param string $country Country for HTTP requests to Google Play.
1171
     *
1172
     * @return GPlayApps Returns an object of its own class to support the call chain.
1173
     */
1174 43
    public function setCountry(string $country): GPlayApps
1175
    {
1176 43
        $this->country = !empty($country) ? $country : self::DEFAULT_COUNTRY;
1177 43
        return $this;
1178
    }
1179
1180
    /**
1181
     * Sets the number of seconds to wait when trying to connect to the server.
1182
     *
1183
     * @param float $connectTimeout Connection timeout in seconds, for example 3.14. Use 0 to wait indefinitely.
1184
     *
1185
     * @return GPlayApps Returns an object of its own class to support the call chain.
1186
     */
1187
    public function setConnectTimeout(float $connectTimeout): GPlayApps
1188
    {
1189
        $this->getHttpClient()->setConnectTimeout($connectTimeout);
1190
        return $this;
1191
    }
1192
1193
    /**
1194
     * Sets the timeout of the request in second.
1195
     *
1196
     * @param float $timeout Waiting timeout in seconds, for example 3.14. Use 0 to wait indefinitely.
1197
     *
1198
     * @return GPlayApps Returns an object of its own class to support the call chain.
1199
     */
1200
    public function setTimeout(float $timeout): GPlayApps
1201
    {
1202
        $this->getHttpClient()->setTimeout($timeout);
1203
        return $this;
1204
    }
1205
}
1206