Passed
Push — master ( 641929...562012 )
by Alexey
06:04 queued 12s
created

GPlayApps::setCache()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 6
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 4
CRAP Score 1

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 3
c 1
b 0
f 0
dl 0
loc 6
ccs 4
cts 4
cp 1
rs 10
cc 1
nc 1
nop 2
crap 1
1
<?php
2
3
declare(strict_types=1);
4
5
/*
6
 * Copyright (c) Ne-Lexa
7
 *
8
 * For the full copyright and license information, please view
9
 * the LICENSE file that was distributed with this source code.
10
 *
11
 * @see https://github.com/Ne-Lexa/google-play-scraper
12
 */
13
14
namespace Nelexa\GPlay;
15
16
use GuzzleHttp\Promise\EachPromise;
17
use GuzzleHttp\Promise\FulfilledPromise;
18
use GuzzleHttp\Psr7\Query;
19
use GuzzleHttp\Psr7\Request as PsrRequest;
20
use GuzzleHttp\Psr7\Uri;
21
use GuzzleHttp\RequestOptions;
22
use Nelexa\GPlay\HttpClient\HttpClient;
23
use Nelexa\GPlay\HttpClient\Request;
24
use Psr\Http\Message\ResponseInterface;
25
use Psr\SimpleCache\CacheInterface;
26
27
/**
28
 * Contains methods for extracting information about Android applications from the Google Play store.
29
 */
30
class GPlayApps
31
{
32
    /** @var string Default request locale. */
33
    public const DEFAULT_LOCALE = 'en_US';
34
35
    /** @var string Default request country. */
36
    public const DEFAULT_COUNTRY = 'us';
37
38
    /** @var string Google Play base url. */
39
    public const GOOGLE_PLAY_URL = 'https://play.google.com';
40
41
    /** @var string Google Play apps url. */
42
    public const GOOGLE_PLAY_APPS_URL = self::GOOGLE_PLAY_URL . '/store/apps';
43
44
    /** @var int Unlimit results. */
45
    public const UNLIMIT = -1;
46
47
    /** @internal */
48
    public const REQ_PARAM_LOCALE = 'hl';
49
50
    /** @internal */
51
    public const REQ_PARAM_COUNTRY = 'gl';
52
53
    /** @internal */
54
    public const REQ_PARAM_ID = 'id';
55
56
    /** @var string Locale (language) for HTTP requests to Google Play */
57
    protected $defaultLocale;
58
59
    /** @var string Country for HTTP requests to Google Play */
60
    protected $defaultCountry;
61
62
    /**
63
     * Creates an object to retrieve data about Android applications from the Google Play store.
64
     *
65
     * @param string $locale  locale (language) for HTTP requests to Google Play
66
     *                        or {@see GPlayApps::DEFAULT_LOCALE}
67
     * @param string $country country for HTTP requests to Google Play
68
     *                        or {@see GPlayApps::DEFAULT_COUNTRY}
69
     *
70
     * @see GPlayApps::DEFAULT_LOCALE Default request locale.
71
     * @see GPlayApps::DEFAULT_COUNTRY Default request country.
72
     */
73 76
    public function __construct(
74
        string $locale = self::DEFAULT_LOCALE,
75
        string $country = self::DEFAULT_COUNTRY
76
    ) {
77
        $this
78 76
            ->setDefaultLocale($locale)
79 76
            ->setDefaultCountry($country)
80
        ;
81
    }
82
83
    /**
84
     * Sets caching for HTTP requests.
85
     *
86
     * @param CacheInterface|null    $cache    PSR-16 Simple Cache instance
87
     * @param \DateInterval|int|null $cacheTtl TTL cached data
88
     *
89
     * @return GPlayApps returns the current class instance to allow method chaining
90
     */
91 12
    public function setCache(?CacheInterface $cache, $cacheTtl = null): self
92
    {
93 12
        $this->getHttpClient()->setCache($cache);
94 12
        $this->setCacheTtl($cacheTtl);
95
96 12
        return $this;
97
    }
98
99
    /**
100
     * Sets cache ttl.
101
     *
102
     * @param \DateInterval|int|null $cacheTtl TTL cached data
103
     *
104
     * @return GPlayApps returns the current class instance to allow method chaining
105
     */
106 12
    public function setCacheTtl($cacheTtl): self
107
    {
108 12
        $this->getHttpClient()->setOption('cache_ttl', $cacheTtl);
109
110 12
        return $this;
111
    }
112
113
    /**
114
     * Returns an instance of HTTP client.
115
     *
116
     * @return HttpClient http client
117
     */
118 64
    protected function getHttpClient(): HttpClient
119
    {
120
        static $httpClient;
121
122 64
        if ($httpClient === null) {
123 1
            $httpClient = new HttpClient();
124
        }
125
126 64
        return $httpClient;
127
    }
128
129
    /**
130
     * Sets the limit of concurrent HTTP requests.
131
     *
132
     * @param int $concurrency maximum number of concurrent HTTP requests
133
     *
134
     * @return GPlayApps returns the current class instance to allow method chaining
135
     */
136 4
    public function setConcurrency(int $concurrency): self
137
    {
138 4
        $this->getHttpClient()->setConcurrency($concurrency);
139
140 4
        return $this;
141
    }
142
143
    /**
144
     * Sets proxy for outgoing HTTP requests.
145
     *
146
     * @param string|null $proxy Proxy url, ex. socks5://127.0.0.1:9050 or https://116.90.233.2:47348
147
     *
148
     * @return GPlayApps returns the current class instance to allow method chaining
149
     *
150
     * @see https://curl.haxx.se/libcurl/c/CURLOPT_PROXY.html Description of proxy URL formats in CURL.
151
     */
152
    public function setProxy(?string $proxy): self
153
    {
154
        $this->getHttpClient()->setOption(RequestOptions::PROXY, $proxy);
155
156
        return $this;
157
    }
158
159
    /**
160
     * Returns the full detail of an application.
161
     *
162
     * For information, you must specify the application ID (android package name).
163
     * The application ID can be viewed in the Google Play store:
164
     * `https://play.google.com/store/apps/details?id=XXXXXX` , where
165
     * XXXXXX is the application id.
166
     *
167
     * Or it can be found in the APK file.
168
     * ```shell
169
     * aapt dump badging file.apk | grep package | awk '{print $2}' | sed s/name=//g | sed s/\'//g
170
     * ```
171
     *
172
     * @param string|Model\AppId $appId google play app id (Android package name)
173
     *
174
     * @throws Exception\GooglePlayException if the application is not exists or other HTTP error
175
     *
176
     * @return Model\AppInfo full detail of an application or exception
177
     *
178
     * @api
179
     */
180 8
    public function getAppInfo($appId): Model\AppInfo
181
    {
182 8
        return $this->getAppsInfo([$appId])[0];
183
    }
184
185
    /**
186
     * Returns the full detail of multiple applications.
187
     *
188
     * The keys of the returned array matches to the passed array.
189
     * HTTP requests are executed in parallel.
190
     *
191
     * @param string[]|Model\AppId[] $appIds array of application ids
192
     *
193
     * @throws Exception\GooglePlayException if the application is not exists or other HTTP error
194
     *
195
     * @return Model\AppInfo[] an array of detailed information for each application
196
     *
197
     * @see GPlayApps::setConcurrency() Sets the limit of concurrent HTTP requests.
198
     *
199
     * @api
200
     */
201 26
    public function getAppsInfo(array $appIds): array
202
    {
203 26
        if (empty($appIds)) {
204 1
            return [];
205
        }
206
207 25
        $infoScraper = new Scraper\AppInfoScraper();
208 25
        $requests = [];
209
210 25
        foreach ($appIds as $key => $appId) {
211 25
            $fullUrl = Util\Caster::castToAppId($appId, $this->defaultLocale, $this->defaultCountry)->getFullUrl();
212 23
            $psrRequest = new PsrRequest('GET', $fullUrl);
213 23
            $requests[$key] = new Request($psrRequest, [], $infoScraper);
0 ignored issues
show
Bug introduced by
$infoScraper of type Nelexa\GPlay\Scraper\AppInfoScraper is incompatible with the type Closure expected by parameter $parseHandler of Nelexa\GPlay\HttpClient\Request::__construct(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

213
            $requests[$key] = new Request($psrRequest, [], /** @scrutinizer ignore-type */ $infoScraper);
Loading history...
214
        }
215
216
        try {
217 23
            return $this->getHttpClient()->requestPool($requests);
218 2
        } catch (\Throwable $e) {
219 2
            throw new Exception\GooglePlayException($e->getMessage(), 1, $e);
220
        }
221
    }
222
223
    /**
224
     * Returns the full details of an application in multiple languages.
225
     *
226
     * HTTP requests are executed in parallel.
227
     *
228
     * @param string|Model\AppId $appId   google Play app ID (Android package name)
229
     * @param string[]           $locales array of locales
230
     *
231
     * @throws Exception\GooglePlayException if the application is not exists or other HTTP error
232
     *
233
     * @return array<string, Model\AppInfo> An array of detailed information for each locale.
234
     *                                      The array key is the locale.
235
     *
236
     * @see GPlayApps::setConcurrency() Sets the limit of concurrent HTTP requests.
237
     *
238
     * @api
239
     */
240 15
    public function getAppInfoForLocales($appId, array $locales): array
241
    {
242 15
        $appId = Util\Caster::castToAppId($appId, $this->defaultLocale, $this->defaultCountry);
243 14
        $apps = [];
244
245 14
        foreach ($locales as $locale) {
246 14
            $apps[$locale] = new Model\AppId($appId->getId(), $locale, $appId->getCountry());
247
        }
248
249 14
        return $this->getAppsInfo($apps);
250
    }
251
252
    /**
253
     * Returns detailed application information for all available locales.
254
     *
255
     * Information is returned only for the description loaded by the developer.
256
     * All locales with automated translation from Google Translate will be ignored.
257
     * HTTP requests are executed in parallel.
258
     *
259
     * @param string|Model\AppId $appId application ID (Android package name) as
260
     *                                  a string or {@see Model\AppId} object
261
     *
262
     * @throws Exception\GooglePlayException if the application is not exists or other HTTP error
263
     *
264
     * @return array<string, Model\AppInfo> An array with detailed information about the application
265
     *                                      on all available locales. The array key is the locale.
266
     *
267
     * @see GPlayApps::setConcurrency() Sets the limit of concurrent HTTP requests.
268
     *
269
     * @api
270
     */
271 14
    public function getAppInfoForAvailableLocales($appId): array
272
    {
273 14
        $list = $this->getAppInfoForLocales($appId, Util\LocaleHelper::SUPPORTED_LOCALES);
274
275 13
        $preferredLocale = self::DEFAULT_LOCALE;
276
277 13
        foreach ($list as $app) {
278 13
            if ($app->isAutoTranslatedDescription()) {
279 12
                $preferredLocale = (string) $app->getTranslatedFromLocale();
280 12
                break;
281
            }
282
        }
283
284
        /**
285
         * @var Model\AppInfo[] $list
286
         */
287 13
        $list = array_filter(
288
            $list,
289 13
            static function (Model\AppInfo $app) {
290 13
                return !$app->isAutoTranslatedDescription();
291
            }
292
        );
293
294 13
        if (!isset($list[$preferredLocale])) {
295
            throw new \RuntimeException('No key ' . $preferredLocale);
296
        }
297 13
        $preferredApp = $list[$preferredLocale];
298 13
        $list = array_filter(
299
            $list,
300 13
            static function (Model\AppInfo $app, string $locale) use ($preferredApp, $list) {
301
                // deletes locales in which there is no translation added, but automatic translation by Google Translate is used.
302 13
                if ($preferredApp->getLocale() === $locale || !$preferredApp->equals($app)) {
303 13
                    if (($pos = strpos($locale, '_')) !== false) {
304 13
                        $rootLang = substr($locale, 0, $pos);
305 13
                        $rootLangLocale = Util\LocaleHelper::getNormalizeLocale($rootLang);
306
307
                        if (
308 13
                            $rootLangLocale !== $locale
309 13
                            && isset($list[$rootLangLocale])
310 13
                            && $list[$rootLangLocale]->equals($app)
311
                        ) {
312
                            // delete duplicate data,
313
                            // for example, delete en_CA, en_IN, en_GB, en_ZA, if there is en_US and they are equals.
314 9
                            return false;
315
                        }
316
                    }
317
318 13
                    return true;
319
                }
320
321 10
                return false;
322
            },
323
            \ARRAY_FILTER_USE_BOTH
324
        );
325
326
        // sorting array keys; the first key is the preferred locale
327 13
        uksort(
328
            $list,
329 13
            static function (
330
                /** @noinspection PhpUnusedParameterInspection */
331
                string $a,
332
                string $b
333
            ) use ($preferredLocale) {
334 13
                return $b === $preferredLocale ? 1 : 0;
335
            }
336
        );
337
338 13
        return $list;
339
    }
340
341
    /**
342
     * Checks if the specified application exists in the Google Play store.
343
     *
344
     * @param string|Model\AppId $appId application ID (Android package name) as
345
     *                                  a string or {@see Model\AppId} object
346
     *
347
     * @return bool returns `true` if the application exists, or `false` if not
348
     *
349
     * @api
350
     */
351 5
    public function existsApp($appId): bool
352
    {
353 5
        $appId = Util\Caster::castToAppId($appId, $this->defaultLocale, $this->defaultCountry);
354 5
        $fullUrl = $appId->getFullUrl();
355 5
        $psrRequest = new PsrRequest('HEAD', $fullUrl);
356 5
        $request = new Request($psrRequest, [
357
            RequestOptions::HTTP_ERRORS => false,
358 5
        ], new Scraper\ExistsAppScraper());
0 ignored issues
show
Bug introduced by
new Nelexa\GPlay\Scraper\ExistsAppScraper() of type Nelexa\GPlay\Scraper\ExistsAppScraper is incompatible with the type Closure expected by parameter $parseHandler of Nelexa\GPlay\HttpClient\Request::__construct(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

358
        ], /** @scrutinizer ignore-type */ new Scraper\ExistsAppScraper());
Loading history...
359
360
        try {
361 5
            return (bool) $this->getHttpClient()->request($request);
362
        } catch (\Throwable $e) {
363
            return false;
364
        }
365
    }
366
367
    /**
368
     * Checks if the specified applications exist in the Google Play store.
369
     * HTTP requests are executed in parallel.
370
     *
371
     * @param string[]|Model\AppId[] $appIds Array of application identifiers.
372
     *                                       The keys of the returned array correspond to the transferred array.
373
     *
374
     * @throws Exception\GooglePlayException if an HTTP error other than 404 is received
375
     *
376
     * @return bool[] An array of information about the existence of each
377
     *                application in the store Google Play. The keys of the returned
378
     *                array matches to the passed array.
379
     *
380
     * @see GPlayApps::setConcurrency() Sets the limit of concurrent HTTP requests.
381
     *
382
     * @api
383
     */
384 1
    public function existsApps(array $appIds): array
385
    {
386 1
        if (empty($appIds)) {
387
            return [];
388
        }
389
390 1
        $parseHandler = new Scraper\ExistsAppScraper();
391 1
        $requests = array_map(function ($appId) use ($parseHandler) {
392 1
            $fullUrl = Util\Caster::castToAppId($appId, $this->defaultLocale, $this->defaultCountry)->getFullUrl();
393 1
            $psrRequest = new PsrRequest('HEAD', $fullUrl);
394
395 1
            return new Request($psrRequest, [
396
                RequestOptions::HTTP_ERRORS => false,
397
            ], $parseHandler);
0 ignored issues
show
Bug introduced by
$parseHandler of type Nelexa\GPlay\Scraper\ExistsAppScraper is incompatible with the type Closure expected by parameter $parseHandler of Nelexa\GPlay\HttpClient\Request::__construct(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

397
            ], /** @scrutinizer ignore-type */ $parseHandler);
Loading history...
398
        }, $appIds);
399
400
        try {
401 1
            return $this->getHttpClient()->requestPool($requests);
402
        } catch (\Throwable $e) {
403
            throw new Exception\GooglePlayException($e->getMessage(), 1, $e);
404
        }
405
    }
406
407
    /**
408
     * Returns reviews of the Android app in the Google Play store.
409
     *
410
     * Getting a lot of reviews can take a lot of time.
411
     *
412
     * @param string|Model\AppId $appId application ID (Android package name) as
413
     *                                  a string or {@see Model\AppId} object
414
     * @param int                $limit Maximum number of reviews. To extract all
415
     *                                  reviews, use {@see GPlayApps::UNLIMIT}.
416
     * @param Enum\SortEnum|null $sort  Sort reviews of the application.
417
     *                                  If null, then sort by the newest reviews.
418
     *
419
     * @throws Exception\GooglePlayException if the application is not exists or other HTTP error
420
     *
421
     * @return Model\Review[] app reviews
422
     *
423
     * @see Enum\SortEnum Contains all valid values for the "sort" parameter.
424
     * @see GPlayApps::UNLIMIT Limit for all available results.
425
     *
426
     * @api
427
     */
428 1
    public function getReviews($appId, int $limit = 100, ?Enum\SortEnum $sort = null): array
429
    {
430 1
        $appId = Util\Caster::castToAppId($appId, $this->defaultLocale, $this->defaultCountry);
431 1
        $sort = $sort ?? Enum\SortEnum::NEWEST();
432
433 1
        $allCount = 0;
434 1
        $token = null;
435 1
        $allReviews = [];
436
437 1
        $cacheTtl = $sort === Enum\SortEnum::NEWEST()
438 1
            ? \DateInterval::createFromDateString('1 min')
439 1
            : \DateInterval::createFromDateString('1 hour');
440
441
        try {
442
            do {
443 1
                $count = $limit === self::UNLIMIT
444
                    ? Scraper\PlayStoreUiRequest::LIMIT_REVIEW_ON_PAGE
445 1
                    : min(Scraper\PlayStoreUiRequest::LIMIT_REVIEW_ON_PAGE, max($limit - $allCount, 1));
446
447 1
                $psrRequest = Scraper\PlayStoreUiRequest::getReviewsRequest($appId, $count, $sort, $token);
448 1
                $request = new Request($psrRequest, [
449
                    'cache_ttl' => $cacheTtl,
450 1
                ], new Scraper\ReviewsScraper($appId));
0 ignored issues
show
Bug introduced by
new Nelexa\GPlay\Scraper\ReviewsScraper($appId) of type Nelexa\GPlay\Scraper\ReviewsScraper is incompatible with the type Closure expected by parameter $parseHandler of Nelexa\GPlay\HttpClient\Request::__construct(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

450
                ], /** @scrutinizer ignore-type */ new Scraper\ReviewsScraper($appId));
Loading history...
451 1
                [$reviews, $token] = $this->getHttpClient()->request($request);
452 1
                $allCount += \count($reviews);
453 1
                $allReviews[] = $reviews;
454 1
            } while ($token !== null && ($limit === self::UNLIMIT || $allCount < $limit));
455
        } catch (\Throwable $e) {
456
            throw new Exception\GooglePlayException($e->getMessage(), 1, $e);
457
        }
458
459 1
        $reviews = empty($allReviews) ? $allReviews : array_merge(...$allReviews);
460 1
        if ($limit !== self::UNLIMIT) {
461 1
            $reviews = \array_slice($reviews, 0, $limit);
462
        }
463
464 1
        return $reviews;
465
    }
466
467
    /**
468
     * Returns review of the Android app in the Google Play store by review id.
469
     *
470
     * @param string|Model\AppId $appId    application ID (Android package name) as
471
     *                                     a string or {@see Model\AppId} object
472
     * @param string             $reviewId review id
473
     *
474
     * @throws Exception\GooglePlayException if the application is not exists or other HTTP error
475
     *
476
     * @return Model\Review app review
477
     */
478 1
    public function getReviewById($appId, string $reviewId): Model\Review
479
    {
480 1
        $appId = Util\Caster::castToAppId($appId, $this->defaultLocale, $this->defaultCountry);
481
482
        try {
483 1
            $queryString = Query::build([
484 1
                self::REQ_PARAM_ID => $appId->getId(),
485 1
                self::REQ_PARAM_LOCALE => $appId->getLocale(),
486 1
                self::REQ_PARAM_COUNTRY => $appId->getCountry(),
487
                'reviewId' => $reviewId,
488
            ]);
489
490 1
            $detailUrl = self::GOOGLE_PLAY_APPS_URL . '/details?' . $queryString;
491
492 1
            $request = new Request(
493 1
                new PsrRequest('GET', $detailUrl),
494
                [],
495 1
                new Scraper\AppSpecificReviewScraper($appId)
0 ignored issues
show
Bug introduced by
new Nelexa\GPlay\Scraper...icReviewScraper($appId) of type Nelexa\GPlay\Scraper\AppSpecificReviewScraper is incompatible with the type Closure expected by parameter $parseHandler of Nelexa\GPlay\HttpClient\Request::__construct(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

495
                /** @scrutinizer ignore-type */ new Scraper\AppSpecificReviewScraper($appId)
Loading history...
496
            );
497
498 1
            return $this->getHttpClient()->request($request);
499
        } catch (\Throwable $e) {
500
            throw new Exception\GooglePlayException($e->getMessage(), 1, $e);
501
        }
502
    }
503
504
    /**
505
     * Returns a list of permissions for the application.
506
     *
507
     * @param string|Model\AppId $appId application ID (Android package name) as
508
     *                                  a string or {@see Model\AppId} object
509
     *
510
     * @throws Exception\GooglePlayException if the application is not exists or other HTTP error
511
     *
512
     * @return Model\Permission[] an array of permissions for the application
513
     *
514
     * @api
515
     */
516 1
    public function getPermissions($appId): array
517
    {
518 1
        $appId = Util\Caster::castToAppId($appId, $this->defaultLocale, $this->defaultCountry);
519
520
        try {
521 1
            $psrRequest = Scraper\PlayStoreUiRequest::getPermissionsRequest($appId);
522
523 1
            return $this->getHttpClient()->request(
524 1
                new Request(
525
                    $psrRequest,
526
                    [],
527 1
                    new Scraper\PermissionScraper()
0 ignored issues
show
Bug introduced by
new Nelexa\GPlay\Scraper\PermissionScraper() of type Nelexa\GPlay\Scraper\PermissionScraper is incompatible with the type Closure expected by parameter $parseHandler of Nelexa\GPlay\HttpClient\Request::__construct(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

527
                    /** @scrutinizer ignore-type */ new Scraper\PermissionScraper()
Loading history...
528
                )
529
            );
530
        } catch (\Throwable $e) {
531
            throw new Exception\GooglePlayException($e->getMessage(), 1, $e);
532
        }
533
    }
534
535
    /**
536
     * Returns an array of application categories from the Google Play store.
537
     *
538
     * @throws Exception\GooglePlayException if HTTP error is received
539
     *
540
     * @return Model\Category[] array of application categories
541
     *
542
     * @api
543
     */
544 2
    public function getCategories(): array
545
    {
546 2
        $url = self::GOOGLE_PLAY_APPS_URL . '?' . http_build_query(
547
            [
548 2
                self::REQ_PARAM_LOCALE => $this->defaultLocale,
549
            ]
550
        );
551
552
        try {
553 2
            return $this->getHttpClient()->request(
554 2
                new Request(
555 2
                    new PsrRequest('GET', $url),
556
                    [],
557 2
                    new Scraper\CategoriesScraper()
0 ignored issues
show
Bug introduced by
new Nelexa\GPlay\Scraper\CategoriesScraper() of type Nelexa\GPlay\Scraper\CategoriesScraper is incompatible with the type Closure expected by parameter $parseHandler of Nelexa\GPlay\HttpClient\Request::__construct(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

557
                    /** @scrutinizer ignore-type */ new Scraper\CategoriesScraper()
Loading history...
558
                )
559
            );
560
        } catch (\Throwable $e) {
561
            throw new Exception\GooglePlayException($e->getMessage(), 1, $e);
562
        }
563
    }
564
565
    /**
566
     * Returns an array of application categories from the Google Play store for the specified locales.
567
     *
568
     * HTTP requests are executed in parallel.
569
     *
570
     * @param string[] $locales array of locales
571
     *
572
     * @throws Exception\GooglePlayException if HTTP error is received
573
     *
574
     * @return Model\Category[][] array of application categories by locale
575
     *
576
     * @see GPlayApps::setConcurrency() Sets the limit of concurrent HTTP requests.
577
     *
578
     * @api
579
     */
580 2
    public function getCategoriesForLocales(array $locales): array
581
    {
582 2
        if (empty($locales)) {
583
            return [];
584
        }
585
586 2
        $url = self::GOOGLE_PLAY_APPS_URL;
587 2
        $locales = Util\LocaleHelper::getNormalizeLocales($locales);
588 2
        $parseHandler = new Scraper\CategoriesScraper();
589 2
        $requests = [];
590
591 2
        foreach ($locales as $locale) {
592 2
            $requestUrl = $url . '?' . http_build_query([self::REQ_PARAM_LOCALE => $locale]);
593 2
            $requests[$locale] = new Request(
594 2
                new PsrRequest('GET', $requestUrl),
595
                [],
596
                $parseHandler
0 ignored issues
show
Bug introduced by
$parseHandler of type Nelexa\GPlay\Scraper\CategoriesScraper is incompatible with the type Closure expected by parameter $parseHandler of Nelexa\GPlay\HttpClient\Request::__construct(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

596
                /** @scrutinizer ignore-type */ $parseHandler
Loading history...
597
            );
598
        }
599
600
        try {
601 2
            return $this->getHttpClient()->requestPool($requests);
602
        } catch (\Throwable $e) {
603
            throw new Exception\GooglePlayException($e->getMessage(), 1, $e);
604
        }
605
    }
606
607
    /**
608
     * Returns an array of categories from the Google Play store for all available locales.
609
     *
610
     * @throws Exception\GooglePlayException if HTTP error is received
611
     *
612
     * @return Model\Category[][] array of application categories by locale
613
     *
614
     * @see GPlayApps::setConcurrency() Sets the limit of concurrent HTTP requests.
615
     *
616
     * @api
617
     */
618 1
    public function getCategoriesForAvailableLocales(): array
619
    {
620 1
        return $this->getCategoriesForLocales(Util\LocaleHelper::SUPPORTED_LOCALES);
621
    }
622
623
    /**
624
     * Returns information about the developer: name, icon, cover, description and website address.
625
     *
626
     * @param string|Model\Developer|Model\App $developerId developer id as
627
     *                                                      string, {@see Model\Developer}
628
     *                                                      or {@see Model\App} object
629
     *
630
     * @throws Exception\GooglePlayException if HTTP error is received
631
     *
632
     * @return Model\Developer information about the application developer
633
     *
634
     * @see GPlayApps::getDeveloperInfoForLocales() Returns information about the developer for the locale array.
635
     *
636
     * @api
637
     */
638 5
    public function getDeveloperInfo($developerId): Model\Developer
639
    {
640 5
        $developerId = Util\Caster::castToDeveloperId($developerId);
641
642 5
        if (!is_numeric($developerId)) {
643 3
            throw new Exception\GooglePlayException(
644 3
                sprintf(
645
                    'Developer "%s" does not have a personalized page on Google Play.',
646
                    $developerId
647
                )
648
            );
649
        }
650
651 2
        $url = self::GOOGLE_PLAY_APPS_URL . '/dev?' . http_build_query([
652
            self::REQ_PARAM_ID => $developerId,
653 2
            self::REQ_PARAM_LOCALE => $this->defaultLocale,
654
        ]);
655
656
        try {
657 2
            return $this->getHttpClient()->request(
658 2
                new Request(
659 2
                    new PsrRequest('GET', $url),
660
                    [],
661 2
                    new Scraper\DeveloperInfoScraper()
0 ignored issues
show
Bug introduced by
new Nelexa\GPlay\Scraper\DeveloperInfoScraper() of type Nelexa\GPlay\Scraper\DeveloperInfoScraper is incompatible with the type Closure expected by parameter $parseHandler of Nelexa\GPlay\HttpClient\Request::__construct(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

661
                    /** @scrutinizer ignore-type */ new Scraper\DeveloperInfoScraper()
Loading history...
662
                )
663
            );
664 1
        } catch (\Throwable $e) {
665 1
            throw new Exception\GooglePlayException($e->getMessage(), 1, $e);
666
        }
667
    }
668
669
    /**
670
     * Returns information about the developer for the specified locales.
671
     *
672
     * @param string|Model\Developer|Model\App $developerId developer id as
673
     *                                                      string, {@see Model\Developer}
674
     *                                                      or {@see Model\App} object
675
     * @param string[]                         $locales     array of locales
676
     *
677
     * @throws Exception\GooglePlayException if HTTP error is received
678
     *
679
     * @return Model\Developer[] an array with information about the application developer
680
     *                           for each requested locale
681
     *
682
     * @see GPlayApps::setConcurrency() Sets the limit of concurrent HTTP requests.
683
     * @see GPlayApps::getDeveloperInfo() Returns information about the developer: name,
684
     *     icon, cover, description and website address.
685
     *
686
     * @api
687
     */
688 1
    public function getDeveloperInfoForLocales($developerId, array $locales = []): array
689
    {
690 1
        if (empty($locales)) {
691
            return [];
692
        }
693 1
        $locales = Util\LocaleHelper::getNormalizeLocales($locales);
694
695 1
        $id = Util\Caster::castToDeveloperId($developerId);
696
697 1
        if (!is_numeric($id)) {
698
            throw new Exception\GooglePlayException(
699
                sprintf(
700
                    'Developer "%s" does not have a personalized page on Google Play.',
701
                    $id
702
                )
703
            );
704
        }
705
706 1
        $requests = [];
707 1
        $url = self::GOOGLE_PLAY_APPS_URL . '/dev';
708 1
        $parseHandler = new Scraper\DeveloperInfoScraper();
709
710 1
        foreach ($locales as $locale) {
711 1
            $requestUrl = $url . '?' . http_build_query(
712
                [
713
                    self::REQ_PARAM_ID => $id,
714
                    self::REQ_PARAM_LOCALE => $locale,
715
                ]
716
            );
717 1
            $requests[$locale] = new Request(
718 1
                new PsrRequest('GET', $requestUrl),
719
                [],
720
                $parseHandler
0 ignored issues
show
Bug introduced by
$parseHandler of type Nelexa\GPlay\Scraper\DeveloperInfoScraper is incompatible with the type Closure expected by parameter $parseHandler of Nelexa\GPlay\HttpClient\Request::__construct(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

720
                /** @scrutinizer ignore-type */ $parseHandler
Loading history...
721
            );
722
        }
723
724
        try {
725 1
            return $this->getHttpClient()->requestPool($requests);
726
        } catch (\Throwable $e) {
727
            throw new Exception\GooglePlayException($e->getMessage(), 1, $e);
728
        }
729
    }
730
731
    /**
732
     * Returns an array of applications from the Google Play store by developer id.
733
     *
734
     * @param string|Model\Developer|Model\App $developerId developer id as
735
     *                                                      string, {@see Model\Developer}
736
     *                                                      or {@see Model\App} object
737
     *
738
     * @throws Exception\GooglePlayException if HTTP error is received
739
     *
740
     * @return Model\App[] an array of applications with basic information
741
     *
742
     * @api
743
     */
744 1
    public function getDeveloperApps($developerId): array
745
    {
746 1
        $developerId = Util\Caster::castToDeveloperId($developerId);
747
748
        $query = [
749
            self::REQ_PARAM_ID => $developerId,
750 1
            self::REQ_PARAM_LOCALE => $this->defaultLocale,
751 1
            self::REQ_PARAM_COUNTRY => $this->defaultCountry,
752
        ];
753
754 1
        if (is_numeric($developerId)) {
755 1
            $developerUrl = self::GOOGLE_PLAY_APPS_URL . '/dev?' . http_build_query($query);
756
757
            try {
758
                /**
759
                 * @var string|null $developerUrl
760
                 */
761 1
                $developerUrl = $this->getHttpClient()->request(
762 1
                    new Request(
763 1
                        new PsrRequest('GET', $developerUrl),
764
                        [],
765 1
                        new Scraper\FindDevAppsUrlScraper()
0 ignored issues
show
Bug introduced by
new Nelexa\GPlay\Scraper\FindDevAppsUrlScraper() of type Nelexa\GPlay\Scraper\FindDevAppsUrlScraper is incompatible with the type Closure expected by parameter $parseHandler of Nelexa\GPlay\HttpClient\Request::__construct(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

765
                        /** @scrutinizer ignore-type */ new Scraper\FindDevAppsUrlScraper()
Loading history...
766
                    )
767
                );
768
769 1
                if ($developerUrl === null) {
770
                    return [];
771
                }
772
773 1
                $developerUrl .= '&' . self::REQ_PARAM_LOCALE . '=' . urlencode($this->defaultLocale)
774 1
                    . '&' . self::REQ_PARAM_COUNTRY . '=' . urlencode($this->defaultCountry);
775
            } catch (\Throwable $e) {
776 1
                throw new Exception\GooglePlayException($e->getMessage(), 1, $e);
777
            }
778
        } else {
779 1
            $developerUrl = self::GOOGLE_PLAY_APPS_URL . '/developer?' . http_build_query($query);
780
        }
781
782 1
        return $this->fetchAppsFromClusterPage(
783
            $developerUrl,
784
            self::UNLIMIT
785
        );
786
    }
787
788
    /**
789
     * Returns an iterator of applications from the Google Play store for the specified cluster page.
790
     *
791
     * @param string $clusterPageUrl cluster page url
792
     *
793
     * @throws \Nelexa\GPlay\Exception\GooglePlayException
794
     *
795
     * @return \Generator<Model\App> an iterator with basic information about applications
796
     */
797 18
    public function getClusterApps(string $clusterPageUrl): iterable
798
    {
799 18
        $clusterUri = new Uri($clusterPageUrl);
800 18
        $query = Query::parse($clusterUri->getQuery());
801
802 18
        if (!isset($query[self::REQ_PARAM_LOCALE])) {
803 14
            $query[self::REQ_PARAM_LOCALE] = $this->defaultLocale;
804
        }
805
806 18
        if (!isset($query[self::REQ_PARAM_COUNTRY])) {
807 15
            $query[self::REQ_PARAM_COUNTRY] = $this->defaultCountry;
808
        }
809
810 18
        $clusterUri = $clusterUri->withQuery(Query::build($query));
811 18
        $clusterPageUrl = (string) $clusterUri;
812
813
        try {
814 18
            [$apps, $token] = $this->getHttpClient()->request(
815 18
                new Request(
816 18
                    new PsrRequest('GET', $clusterPageUrl),
817
                    [],
818 18
                    new Scraper\ClusterAppsScraper()
0 ignored issues
show
Bug introduced by
new Nelexa\GPlay\Scraper\ClusterAppsScraper() of type Nelexa\GPlay\Scraper\ClusterAppsScraper is incompatible with the type Closure expected by parameter $parseHandler of Nelexa\GPlay\HttpClient\Request::__construct(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

818
                    /** @scrutinizer ignore-type */ new Scraper\ClusterAppsScraper()
Loading history...
819
                )
820
            );
821
822 18
            foreach ($apps as $app) {
823 18
                yield $app;
824
            }
825
826 18
            while ($token !== null) {
827 18
                $request = Scraper\PlayStoreUiRequest::getAppsRequest(
828 18
                    $query[self::REQ_PARAM_LOCALE],
829 18
                    $query[self::REQ_PARAM_COUNTRY],
830
                    Scraper\PlayStoreUiRequest::LIMIT_APPS_ON_PAGE,
831
                    $token
832
                );
833
834 18
                [$apps, $token] = $this->getHttpClient()->request(
835 18
                    new Request(
836
                        $request,
837
                        [],
838 18
                        new Scraper\PlayStoreUiAppsScraper()
0 ignored issues
show
Bug introduced by
new Nelexa\GPlay\Scraper\PlayStoreUiAppsScraper() of type Nelexa\GPlay\Scraper\PlayStoreUiAppsScraper is incompatible with the type Closure expected by parameter $parseHandler of Nelexa\GPlay\HttpClient\Request::__construct(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

838
                        /** @scrutinizer ignore-type */ new Scraper\PlayStoreUiAppsScraper()
Loading history...
839
                    )
840
                );
841
842 18
                foreach ($apps as $app) {
843 18
                    yield $app;
844
                }
845
            }
846
        } catch (\Throwable $e) {
847
            throw new Exception\GooglePlayException($e->getMessage(), 1, $e);
848
        }
849
    }
850
851
    /**
852
     * Returns a list of applications with basic information.
853
     *
854
     * @param string $clusterPageUrl cluster page URL
855
     * @param int    $limit          Maximum number of applications. To extract all
856
     *                               applications, use {@see GPlayApps::UNLIMIT}.
857
     *
858
     * @throws Exception\GooglePlayException if the application is not exists or other HTTP error
859
     *
860
     * @return Model\App[] array of applications with basic information about them
861
     *
862
     * @see GPlayApps::UNLIMIT Limit for all available results.
863
     */
864 3
    protected function fetchAppsFromClusterPage(
865
        string $clusterPageUrl,
866
        int $limit
867
    ): array {
868 3
        $apps = [];
869 3
        $count = 0;
870
871 3
        foreach ($this->getClusterApps($clusterPageUrl) as $app) {
872 3
            $apps[] = $app;
873 3
            ++$count;
874 3
            if ($count === $limit) {
875
                break;
876
            }
877
        }
878
879 3
        return $apps;
880
    }
881
882
    /**
883
     * Returns an array of similar applications with basic information about
884
     * them in the Google Play store.
885
     *
886
     * @param string|Model\AppId $appId application ID (Android package name)
887
     *                                  as a string or {@see Model\AppId} object
888
     * @param int                $limit The maximum number of similar applications.
889
     *                                  To extract all similar applications,
890
     *                                  use {@see GPlayApps::UNLIMIT}.
891
     *
892
     * @throws Exception\GooglePlayException if the application is not exists or other HTTP error
893
     *
894
     * @return Model\App[] an array of applications with basic information about them
895
     *
896
     * @see GPlayApps::UNLIMIT Limit for all available results.
897
     *
898
     * @api
899
     */
900 1
    public function getSimilarApps($appId, int $limit = 50): array
901
    {
902 1
        $appId = Util\Caster::castToAppId($appId, $this->defaultLocale, $this->defaultCountry);
903
904
        try {
905
            /** @var string|null $similarAppsUrl */
906 1
            $similarAppsUrl = $this->getHttpClient()->request(
907 1
                new Request(
908 1
                    new PsrRequest('GET', $appId->getFullUrl()),
909
                    [],
910 1
                    new Scraper\FindSimilarAppsUrlScraper($appId)
0 ignored issues
show
Bug introduced by
new Nelexa\GPlay\Scraper...rAppsUrlScraper($appId) of type Nelexa\GPlay\Scraper\FindSimilarAppsUrlScraper is incompatible with the type Closure expected by parameter $parseHandler of Nelexa\GPlay\HttpClient\Request::__construct(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

910
                    /** @scrutinizer ignore-type */ new Scraper\FindSimilarAppsUrlScraper($appId)
Loading history...
911
                )
912
            );
913
914 1
            if ($similarAppsUrl === null) {
915
                return [];
916
            }
917
918 1
            return $this->fetchAppsFromClusterPage(
919
                $similarAppsUrl,
920
                $limit
921
            );
922
        } catch (\Throwable $e) {
923
            throw new Exception\GooglePlayException($e->getMessage(), 1, $e);
924
        }
925
    }
926
927
    /**
928
     * Returns an iterator of cluster pages.
929
     *
930
     * @param string|Model\Category|Enum\CategoryEnum|null $category application category as
931
     *                                                               string, {@see Model\Category},
932
     *                                                               {@see Enum\CategoryEnum} or
933
     *                                                               `null` for all categories
934
     * @param Enum\AgeEnum|null                            $age      age limit or `null` for no limit
935
     * @param string|null                                  $path     `top`, `new` or `null`
936
     *
937
     * @throws \Nelexa\GPlay\Exception\GooglePlayException
938
     *
939
     * @return iterable<Model\ClusterPage> an iterator of cluster pages
940
     */
941 19
    public function getClusterPages($category = null, ?Enum\AgeEnum $age = null, ?string $path = null): iterable
942
    {
943
        $queryParams = [
944 19
            self::REQ_PARAM_LOCALE => $this->defaultLocale,
945 19
            self::REQ_PARAM_COUNTRY => $this->defaultCountry,
946
        ];
947
948 19
        if ($age !== null) {
949 1
            $queryParams['age'] = $age->value();
950
        }
951
952 19
        $url = self::GOOGLE_PLAY_APPS_URL;
953
954 19
        if ($path !== null) {
955 12
            $url .= '/' . $path;
956
        }
957
958 19
        if ($category !== null) {
959 14
            $url .= '/category/' . Util\Caster::castToCategoryId($category);
960
        }
961 19
        $url .= '?' . http_build_query($queryParams);
962
963 19
        ['results' => $results, 'token' => $token] = $this->getHttpClient()->request(
964 19
            new Request(
965 19
                new PsrRequest('GET', $url),
966
                [],
967 19
                new Scraper\ClusterPagesFromListAppsScraper()
0 ignored issues
show
Bug introduced by
new Nelexa\GPlay\Scraper...esFromListAppsScraper() of type Nelexa\GPlay\Scraper\Clu...agesFromListAppsScraper is incompatible with the type Closure expected by parameter $parseHandler of Nelexa\GPlay\HttpClient\Request::__construct(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

967
                /** @scrutinizer ignore-type */ new Scraper\ClusterPagesFromListAppsScraper()
Loading history...
968
            )
969
        );
970
971 19
        foreach ($results as $result) {
972 17
            yield $result;
973
        }
974
975 18
        while ($token !== null) {
976
            try {
977 5
                $psrRequest = Scraper\PlayStoreUiRequest::getClusterPagesRequest(
978
                    $token,
979 5
                    $this->defaultLocale,
980 5
                    $this->defaultCountry
981
                );
982
983 5
                ['results' => $results, 'token' => $token] = $this->getHttpClient()->request(
984 5
                    new Request(
985
                        $psrRequest,
986
                        [],
987 5
                        new Scraper\ClusterPagesFromClusterResponseScraper()
0 ignored issues
show
Bug introduced by
new Nelexa\GPlay\Scraper...lusterResponseScraper() of type Nelexa\GPlay\Scraper\Clu...mClusterResponseScraper is incompatible with the type Closure expected by parameter $parseHandler of Nelexa\GPlay\HttpClient\Request::__construct(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

987
                        /** @scrutinizer ignore-type */ new Scraper\ClusterPagesFromClusterResponseScraper()
Loading history...
988
                    )
989
                );
990
991 5
                foreach ($results as $result) {
992 5
                    yield $result;
993
                }
994
            } catch (\Throwable $e) {
995
                throw new Exception\GooglePlayException($e->getMessage(), 1, $e);
996
            }
997
        }
998
    }
999
1000
    /**
1001
     * Returns the Google Play search suggests.
1002
     *
1003
     * @param string $query search query
1004
     *
1005
     * @throws Exception\GooglePlayException if HTTP error is received
1006
     *
1007
     * @return string[] array containing search suggestions
1008
     *
1009
     * @api
1010
     */
1011 2
    public function getSearchSuggestions(string $query): array
1012
    {
1013 2
        $query = trim($query);
1014
1015 2
        if ($query === '') {
1016
            return [];
1017
        }
1018
1019
        try {
1020 2
            $psrRequest = Scraper\PlayStoreUiRequest::getSuggestRequest(
1021
                $query,
1022 2
                $this->defaultLocale,
1023 2
                $this->defaultCountry
1024
            );
1025
1026
            /** @var string[] $suggestions */
1027 2
            $suggestions = $this->getHttpClient()->request(
1028 2
                new Request(
1029
                    $psrRequest,
1030
                    [],
1031 2
                    new Scraper\SuggestScraper()
0 ignored issues
show
Bug introduced by
new Nelexa\GPlay\Scraper\SuggestScraper() of type Nelexa\GPlay\Scraper\SuggestScraper is incompatible with the type Closure expected by parameter $parseHandler of Nelexa\GPlay\HttpClient\Request::__construct(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

1031
                    /** @scrutinizer ignore-type */ new Scraper\SuggestScraper()
Loading history...
1032
                )
1033
            );
1034
        } catch (\Throwable $e) {
1035
            throw new Exception\GooglePlayException($e->getMessage(), 1, $e);
1036
        }
1037
1038 2
        return $suggestions;
1039
    }
1040
1041
    /**
1042
     * Returns a list of applications from the Google Play store for a search query.
1043
     *
1044
     * @param string              $query search query
1045
     * @param int                 $limit the limit on the number of search results
1046
     * @param Enum\PriceEnum|null $price price category or `null`
1047
     *
1048
     * @throws Exception\GooglePlayException if HTTP error is received
1049
     *
1050
     * @return Model\App[] an array of applications with basic information
1051
     *
1052
     * @see Enum\PriceEnum Contains all valid values for the "price" parameter.
1053
     * @see GPlayApps::UNLIMIT Limit for all available results.
1054
     *
1055
     * @api
1056
     */
1057 1
    public function search(string $query, int $limit = 50, ?Enum\PriceEnum $price = null): array
1058
    {
1059 1
        $query = trim($query);
1060
1061 1
        if (empty($query)) {
1062
            throw new \InvalidArgumentException('Search query missing');
1063
        }
1064 1
        $price = $price ?? Enum\PriceEnum::ALL();
1065
1066
        $params = [
1067
            'c' => 'apps',
1068
            'q' => $query,
1069 1
            self::REQ_PARAM_LOCALE => $this->defaultLocale,
1070 1
            self::REQ_PARAM_COUNTRY => $this->defaultCountry,
1071 1
            'price' => $price->value(),
1072
        ];
1073 1
        $clusterPageUrl = self::GOOGLE_PLAY_URL . '/store/search?' . http_build_query($params);
1074
1075 1
        return $this->fetchAppsFromClusterPage(
1076
            $clusterPageUrl,
1077
            $limit
1078
        );
1079
    }
1080
1081
    /**
1082
     * Returns an array of applications from the Google Play store for the specified category.
1083
     *
1084
     * @param string|Model\Category|Enum\CategoryEnum|null $category application category as
1085
     *                                                               string, {@see Model\Category},
1086
     *                                                               {@see Enum\CategoryEnum} or
1087
     *                                                               `null` for all categories
1088
     * @param Enum\AgeEnum|null                            $age      age limit or null for no limit
1089
     * @param int                                          $limit    limit on the number of results
1090
     *                                                               or {@see GPlayApps::UNLIMIT}
1091
     *                                                               for no limit
1092
     *
1093
     * @throws \Nelexa\GPlay\Exception\GooglePlayException
1094
     *
1095
     * @return Model\App[] an array of applications with basic information
1096
     *
1097
     * @api
1098
     *
1099
     * @see GPlayApps::UNLIMIT Limit for all available results.
1100
     */
1101 5
    public function getListApps($category = null, ?Enum\AgeEnum $age = null, int $limit = self::UNLIMIT): array
1102
    {
1103 5
        return $this->fetchAppsFromClusterPages($category, $age, null, $limit);
1104
    }
1105
1106
    /**
1107
     * Returns an array of **top apps** from the Google Play store for the specified category.
1108
     *
1109
     * @param string|Model\Category|Enum\CategoryEnum|null $category application category as
1110
     *                                                               string, {@see Model\Category},
1111
     *                                                               {@see Enum\CategoryEnum} or
1112
     *                                                               `null` for all categories
1113
     * @param Enum\AgeEnum|null                            $age      age limit or null for no limit
1114
     * @param int                                          $limit    limit on the number of results
1115
     *                                                               or {@see GPlayApps::UNLIMIT}
1116
     *                                                               for no limit
1117
     *
1118
     * @throws \Nelexa\GPlay\Exception\GooglePlayException
1119
     *
1120
     * @return Model\App[] an array of applications with basic information
1121
     *
1122
     * @api
1123
     *
1124
     * @see GPlayApps::UNLIMIT Limit for all available results.
1125
     */
1126 6
    public function getTopApps($category = null, ?Enum\AgeEnum $age = null, int $limit = self::UNLIMIT): array
1127
    {
1128 6
        return $this->fetchAppsFromClusterPages($category, $age, 'top', $limit);
1129
    }
1130
1131
    /**
1132
     * Returns an array of **new apps** from the Google Play store for the specified category.
1133
     *
1134
     * @param string|Model\Category|Enum\CategoryEnum|null $category application category as
1135
     *                                                               string, {@see Model\Category},
1136
     *                                                               {@see Enum\CategoryEnum} or
1137
     *                                                               `null` for all categories
1138
     * @param Enum\AgeEnum|null                            $age      age limit or null for no limit
1139
     * @param int                                          $limit    limit on the number of results
1140
     *                                                               or {@see GPlayApps::UNLIMIT}
1141
     *                                                               for no limit
1142
     *
1143
     * @throws \Nelexa\GPlay\Exception\GooglePlayException
1144
     *
1145
     * @return Model\App[] an array of applications with basic information
1146
     *
1147
     * @api
1148
     *
1149
     * @see GPlayApps::UNLIMIT Limit for all available results.
1150
     */
1151 5
    public function getNewApps($category = null, ?Enum\AgeEnum $age = null, int $limit = self::UNLIMIT): array
1152
    {
1153 5
        return $this->fetchAppsFromClusterPages($category, $age, 'new', $limit);
1154
    }
1155
1156
    /**
1157
     * @param string|Model\Category|Enum\CategoryEnum|null $category
1158
     * @param Enum\AgeEnum|null                            $age
1159
     * @param string|null                                  $path
1160
     * @param int                                          $limit
1161
     *
1162
     * @throws \Nelexa\GPlay\Exception\GooglePlayException
1163
     *
1164
     * @return Model\App[]
1165
     */
1166 16
    protected function fetchAppsFromClusterPages($category, ?Enum\AgeEnum $age, ?string $path, int $limit): array
1167
    {
1168 16
        $apps = [];
1169 16
        $count = 0;
1170 16
        foreach ($this->getClusterPages($category, $age, $path) as $clusterPage) {
1171 14
            foreach ($this->getClusterApps($clusterPage->getUrl()) as $app) {
1172 14
                if (!isset($apps[$app->getId()])) {
1173 14
                    $apps[$app->getId()] = $app;
1174 14
                    ++$count;
1175 14
                    if ($count === $limit) {
1176 1
                        break 2;
1177
                    }
1178
                }
1179
            }
1180
        }
1181
1182 16
        return $apps;
1183
    }
1184
1185
    /**
1186
     * Asynchronously saves images from googleusercontent.com and similar URLs to disk.
1187
     *
1188
     * Before use, you can set the parameters of the width-height of images.
1189
     *
1190
     * Example:
1191
     * ```php
1192
     * $gplay->saveGoogleImages(
1193
     *     $images,
1194
     *     static function (\Nelexa\GPlay\Model\GoogleImage $image): string {
1195
     *         $hash = $image->getHashUrl($hashAlgo = 'md5', $parts = 2, $partLength = 2);
1196
     *         return 'path/to/screenshots/' . $hash . '.{ext}';
1197
     *     },
1198
     *     $overwrite = false
1199
     * );
1200
     * ```
1201
     *
1202
     * @param Model\GoogleImage[] $images           array of {@see Model\GoogleImage} objects
1203
     * @param callable            $destPathCallback The function to which the
1204
     *                                              {@see Model\GoogleImage} object is
1205
     *                                              passed, and you must return the full
1206
     *                                              output. path to save this file.
1207
     * @param bool                $overwrite        overwrite files if exists
1208
     *
1209
     * @return Model\ImageInfo[] returns an array with information about saved images
1210
     *
1211
     * @see Model\GoogleImage Contains a link to the image, allows you to customize its size and download it.
1212
     * @see Model\ImageInfo Contains information about the image.
1213
     *
1214
     * @api
1215
     */
1216 2
    public function saveGoogleImages(
1217
        array $images,
1218
        callable $destPathCallback,
1219
        bool $overwrite = false
1220
    ): array {
1221
        /** @var array<string, \Nelexa\GPlay\Util\LazyStream> $mapping */
1222 2
        $mapping = [];
1223
1224 2
        foreach ($images as $image) {
1225 2
            if (!$image instanceof Model\GoogleImage) {
1226
                throw new \InvalidArgumentException(
1227
                    'An array of ' . Model\GoogleImage::class . ' objects is expected.'
1228
                );
1229
            }
1230 2
            $destPath = $destPathCallback($image);
1231 2
            $url = $image->getUrl();
1232 2
            $mapping[$url] = new Util\LazyStream($destPath, 'w+b');
1233
        }
1234
1235 2
        $httpClient = $this->getHttpClient();
1236 2
        $promises = (static function () use ($mapping, $overwrite, $httpClient) {
1237 2
            foreach ($mapping as $url => $stream) {
1238 2
                $destPath = $stream->getFilename();
1239 2
                $dynamicPath = strpos($destPath, '{url}') !== false;
1240
1241 2
                if (!$overwrite && !$dynamicPath && is_file($destPath)) {
1242
                    yield $url => new FulfilledPromise($url);
1243
                } else {
1244 2
                    yield $url => $httpClient->getClient()
1245 2
                        ->requestAsync(
1246
                            'GET',
1247
                            $url,
1248
                            [
1249
                                RequestOptions::COOKIES => null,
1250
                                RequestOptions::SINK => $stream,
1251
                                RequestOptions::HTTP_ERRORS => true,
1252 2
                                RequestOptions::ON_HEADERS => static function (ResponseInterface $response) use (
1253
                                    $url,
1254
                                    $stream
1255
                                ): void {
1256 2
                                    Model\GoogleImage::onHeaders($response, $url, $stream);
1257
                                },
1258
                            ]
1259
                        )
1260 2
                        ->then(
1261 2
                            static function (
1262
                                /** @noinspection PhpUnusedParameterInspection */
1263
                                ResponseInterface $response
1264
                            ) use ($url) {
1265 1
                                return $url;
1266
                            }
1267
                        )
1268
                    ;
1269
                }
1270
            }
1271
        })();
1272
1273
        /**
1274
         * @var Model\ImageInfo[] $imageInfoList
1275
         */
1276 2
        $imageInfoList = [];
1277 2
        $eachPromise = (new EachPromise(
1278
            $promises,
1279
            [
1280 2
                'concurrency' => $this->getHttpClient()->getConcurrency(),
1281 2
                'fulfilled' => static function (string $url) use (&$imageInfoList, $mapping): void {
1282 1
                    $imageInfoList[] = new Model\ImageInfo($url, $mapping[$url]->getFilename());
1283
                },
1284 2
                'rejected' => static function (\Throwable $reason, string $exceptionUrl) use ($mapping): void {
1285 1
                    foreach ($mapping as $destPath => $url) {
1286 1
                        if (is_file($destPath)) {
1287
                            unlink($destPath);
1288
                        }
1289
                    }
1290
1291 1
                    throw (new Exception\GooglePlayException(
1292 1
                        $reason->getMessage(),
1293 1
                        $reason->getCode(),
1294
                        $reason
1295 1
                    ))->setUrl(
1296
                        $exceptionUrl
1297
                    );
1298
                },
1299
            ]
1300 2
        ))->promise();
1301
1302 2
        if ($eachPromise !== null) {
1303 2
            $eachPromise->wait();
1304
        }
1305
1306 1
        return $imageInfoList;
1307
    }
1308
1309
    /**
1310
     * Returns the locale (language) of the requests.
1311
     *
1312
     * @return string locale (language) for HTTP requests to Google Play
1313
     */
1314 5
    public function getDefaultLocale(): string
1315
    {
1316 5
        return $this->defaultLocale;
1317
    }
1318
1319
    /**
1320
     * Sets the locale (language) of requests.
1321
     *
1322
     * @param string $defaultLocale locale (language) for HTTP requests to Google Play
1323
     *
1324
     * @return GPlayApps returns the current class instance to allow method chaining
1325
     */
1326 76
    public function setDefaultLocale(string $defaultLocale): self
1327
    {
1328 76
        $this->defaultLocale = Util\LocaleHelper::getNormalizeLocale($defaultLocale);
1329
1330 76
        return $this;
1331
    }
1332
1333
    /**
1334
     * Returns the country of the requests.
1335
     *
1336
     * @return string country for HTTP requests to Google Play
1337
     */
1338 5
    public function getDefaultCountry(): string
1339
    {
1340 5
        return $this->defaultCountry;
1341
    }
1342
1343
    /**
1344
     * Sets the country of requests.
1345
     *
1346
     * @param string $defaultCountry country for HTTP requests to Google Play
1347
     *
1348
     * @return GPlayApps returns the current class instance to allow method chaining
1349
     */
1350 76
    public function setDefaultCountry(string $defaultCountry): self
1351
    {
1352 76
        $this->defaultCountry = !empty($defaultCountry)
1353 76
            ? $defaultCountry
1354 2
            : self::DEFAULT_COUNTRY;
1355
1356 76
        return $this;
1357
    }
1358
1359
    /**
1360
     * Sets the number of seconds to wait when trying to connect to the server.
1361
     *
1362
     * @param float $connectTimeout Connection timeout in seconds, for example 3.14. Use 0 to wait indefinitely.
1363
     *
1364
     * @return GPlayApps returns the current class instance to allow method chaining
1365
     */
1366
    public function setConnectTimeout(float $connectTimeout): self
1367
    {
1368
        $this->getHttpClient()->setConnectTimeout($connectTimeout);
1369
1370
        return $this;
1371
    }
1372
1373
    /**
1374
     * Sets the timeout of the request in second.
1375
     *
1376
     * @param float $timeout Waiting timeout in seconds, for example 3.14. Use 0 to wait indefinitely.
1377
     *
1378
     * @return GPlayApps returns the current class instance to allow method chaining
1379
     */
1380
    public function setTimeout(float $timeout): self
1381
    {
1382
        $this->getHttpClient()->setTimeout($timeout);
1383
1384
        return $this;
1385
    }
1386
}
1387