Test Failed
Branch feature/fix-old-client (2ee070)
by Alexey
11:31
created

GPlayApps::fetchAppsFromClusterPages()   A

Complexity

Conditions 5
Paths 5

Size

Total Lines 17
Code Lines 10

Duplication

Lines 0
Ratio 0 %

Importance

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

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

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

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

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

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

528
                    /** @scrutinizer ignore-type */ new Scraper\PermissionScraper()
Loading history...
529
                )
530
            );
531
        } catch (\Throwable $e) {
532
            throw new Exception\GooglePlayException($e->getMessage(), 1, $e);
533
        }
534
    }
535
536
    /**
537
     * Returns an array of application categories from the Google Play store.
538
     *
539
     * @throws Exception\GooglePlayException if HTTP error is received
540
     *
541
     * @return Model\Category[] array of application categories
542
     *
543
     * @api
544
     */
545
    public function getCategories(): array
546
    {
547
        $url = self::GOOGLE_PLAY_APPS_URL . '?' . http_build_query(
548
            [
549
                self::REQ_PARAM_LOCALE => $this->defaultLocale,
550
            ]
551
        );
552
553
        try {
554
            return $this->getHttpClient()->request(
555
                new Request(
556
                    new PsrRequest('GET', $url, [], null, '2.0'),
557
                    [],
558
                    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

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

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

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

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

766
                        /** @scrutinizer ignore-type */ new Scraper\FindDevAppsUrlScraper()
Loading history...
767
                    )
768
                );
769
770
                if ($developerUrl === null) {
771
                    return [];
772
                }
773
774
                $developerUrl .= '&' . self::REQ_PARAM_LOCALE . '=' . urlencode($this->defaultLocale)
775
                    . '&' . self::REQ_PARAM_COUNTRY . '=' . urlencode($this->defaultCountry);
776
            } catch (\Throwable $e) {
777
                throw new Exception\GooglePlayException($e->getMessage(), 1, $e);
778
            }
779
        } else {
780
            $developerUrl = self::GOOGLE_PLAY_APPS_URL . '/developer?' . http_build_query($query);
781
        }
782
783
        return $this->fetchAppsFromClusterPage(
784
            $developerUrl,
785
            self::UNLIMIT
786
        );
787
    }
788
789
    /**
790
     * @param string $clusterPageUrl
791
     *
792
     * @throws \Nelexa\GPlay\Exception\GooglePlayException
793
     *
794
     * @return \Generator<Model\App>
795
     */
796
    public function getClusterApps(string $clusterPageUrl): iterable
797
    {
798
        $clusterUri = new Uri($clusterPageUrl);
799
        $query = Query::parse($clusterUri->getQuery());
800
801
        if (!isset($query[self::REQ_PARAM_LOCALE])) {
802
            $query[self::REQ_PARAM_LOCALE] = $this->defaultLocale;
803
        }
804
805
        if (!isset($query[self::REQ_PARAM_COUNTRY])) {
806
            $query[self::REQ_PARAM_COUNTRY] = $this->defaultCountry;
807
        }
808
809
        $clusterUri = $clusterUri->withQuery(Query::build($query));
810
        $clusterPageUrl = (string) $clusterUri;
811
812
        try {
813
            [$apps, $token] = $this->getHttpClient()->request(
814
                new Request(
815
                    new PsrRequest('GET', $clusterPageUrl, [], null, '2.0'),
816
                    [],
817
                    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

817
                    /** @scrutinizer ignore-type */ new Scraper\ClusterAppsScraper()
Loading history...
818
                )
819
            );
820
821
            foreach ($apps as $app) {
822
                yield $app;
823
            }
824
825
            while ($token !== null) {
826
                $request = Scraper\PlayStoreUiRequest::getAppsRequest(
827
                    $query[self::REQ_PARAM_LOCALE],
828
                    $query[self::REQ_PARAM_COUNTRY],
829
                    Scraper\PlayStoreUiRequest::LIMIT_APPS_ON_PAGE,
830
                    $token
831
                );
832
833
                [$apps, $token] = $this->getHttpClient()->request(
834
                    new Request(
835
                        $request,
836
                        [],
837
                        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

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

911
                    /** @scrutinizer ignore-type */ new Scraper\FindSimilarAppsUrlScraper($appId)
Loading history...
912
                )
913
            );
914
915
            if ($similarAppsUrl === null) {
916
                return [];
917
            }
918
919
            return $this->fetchAppsFromClusterPage(
920
                $similarAppsUrl,
921
                $limit
922
            );
923
        } catch (\Throwable $e) {
924
            throw new Exception\GooglePlayException($e->getMessage(), 1, $e);
925
        }
926
    }
927
928
    /**
929
     * @param string|Model\Category|Enum\CategoryEnum|null $category
930
     * @param Enum\AgeEnum|null                            $age
931
     * @param string|null                                  $path
932
     *
933
     * @return iterable
934
     */
935
    public function getClusterPages($category = null, ?AgeEnum $age = null, ?string $path = null): iterable
936
    {
937
        $queryParams = [
938
            self::REQ_PARAM_LOCALE => $this->defaultLocale,
939
            self::REQ_PARAM_COUNTRY => $this->defaultCountry,
940
        ];
941
942
        if ($age !== null) {
943
            $queryParams['age'] = $age->value();
944
        }
945
946
        $url = self::GOOGLE_PLAY_APPS_URL;
947
948
        if ($path !== null) {
949
            $url .= '/' . $path;
950
        }
951
952
        if ($category !== null) {
953
            $url .= '/category/' . Util\Caster::castToCategoryId($category);
954
        }
955
        $url .= '?' . http_build_query($queryParams);
956
957
        ['results' => $results, 'token' => $token] = $this->getHttpClient()->request(
958
            new Request(
959
                new PsrRequest('GET', $url, [], null, '2.0'),
960
                [],
961
                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

961
                /** @scrutinizer ignore-type */ new Scraper\ClusterPagesFromListAppsScraper()
Loading history...
962
            )
963
        );
964
965
        foreach ($results as $result) {
966
            yield $result;
967
        }
968
969
        while ($token !== null) {
970
            try {
971
                $psrRequest = Scraper\PlayStoreUiRequest::getClusterPagesRequest(
972
                    $token,
973
                    $this->defaultLocale,
974
                    $this->defaultCountry
975
                );
976
977
                ['results' => $results, 'token' => $token] = $this->getHttpClient()->request(
978
                    new Request(
979
                        $psrRequest,
980
                        [],
981
                        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

981
                        /** @scrutinizer ignore-type */ new Scraper\ClusterPagesFromClusterResponseScraper()
Loading history...
982
                    )
983
                );
984
985
                foreach ($results as $result) {
986
                    yield $result;
987
                }
988
            } catch (\Throwable $e) {
989
                throw new Exception\GooglePlayException($e->getMessage(), 1, $e);
990
            }
991
        }
992
    }
993
994
    /**
995
     * Returns the Google Play search suggests.
996
     *
997
     * @param string $query search query
998
     *
999
     * @throws Exception\GooglePlayException if HTTP error is received
1000
     *
1001
     * @return string[] array containing search suggestions
1002
     *
1003
     * @api
1004
     */
1005
    public function getSearchSuggestions(string $query): array
1006
    {
1007
        $query = trim($query);
1008
1009
        if ($query === '') {
1010
            return [];
1011
        }
1012
1013
        try {
1014
            $psrRequest = Scraper\PlayStoreUiRequest::getSuggestRequest(
1015
                $query,
1016
                $this->defaultLocale,
1017
                $this->defaultCountry
1018
            );
1019
1020
            /** @var string[] $suggestions */
1021
            $suggestions = $this->getHttpClient()->request(
1022
                new Request(
1023
                    $psrRequest,
1024
                    [],
1025
                    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

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