Passed
Push — master ( 81593c...33129c )
by Alexey
10:12 queued 12s
created

GPlayApps::getTopSellingPaidApps()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 2
CRAP Score 1

Importance

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

215
            $requests[$key] = new Request($psrRequest, [], /** @scrutinizer ignore-type */ $infoScraper);
Loading history...
216
        }
217
218
        try {
219 11
            return $this->getHttpClient()->requestPool($requests);
220 2
        } catch (\Throwable $e) {
221 2
            throw new Exception\GooglePlayException($e->getMessage(), 1, $e);
222
        }
223
    }
224
225
    /**
226
     * Returns the full details of an application in multiple languages.
227
     *
228
     * HTTP requests are executed in parallel.
229
     *
230
     * @param string|Model\AppId $appId   google Play app ID (Android package name)
231
     * @param string[]           $locales array of locales
232
     *
233
     * @throws Exception\GooglePlayException if the application is not exists or other HTTP error
234
     *
235
     * @return array<string, Model\AppInfo> An array of detailed information for each locale.
236
     *                                      The array key is the locale.
237
     *
238
     * @see GPlayApps::setConcurrency() Sets the limit of concurrent HTTP requests.
239
     *
240
     * @api
241
     */
242 3
    public function getAppInfoForLocales($appId, array $locales): array
243
    {
244 3
        $appId = Util\Caster::castToAppId($appId, $this->defaultLocale, $this->defaultCountry);
245 2
        $apps = [];
246
247 2
        foreach ($locales as $locale) {
248 2
            $apps[$locale] = new Model\AppId($appId->getId(), $locale, $appId->getCountry());
249
        }
250
251 2
        return $this->getAppsInfo($apps);
252
    }
253
254
    /**
255
     * Returns detailed application information for all available locales.
256
     *
257
     * Information is returned only for the description loaded by the developer.
258
     * All locales with automated translation from Google Translate will be ignored.
259
     * HTTP requests are executed in parallel.
260
     *
261
     * @param string|Model\AppId $appId application ID (Android package name) as
262
     *                                  a string or {@see Model\AppId} object
263
     *
264
     * @throws Exception\GooglePlayException if the application is not exists or other HTTP error
265
     *
266
     * @return array<string, Model\AppInfo> An array with detailed information about the application
267
     *                                      on all available locales. The array key is the locale.
268
     *
269
     * @see GPlayApps::setConcurrency() Sets the limit of concurrent HTTP requests.
270
     *
271
     * @api
272
     */
273 2
    public function getAppInfoForAvailableLocales($appId): array
274
    {
275 2
        return $this->getAppInfoForLocales($appId, Util\LocaleHelper::SUPPORTED_LOCALES);
276
    }
277
278
    /**
279
     * Checks if the specified application exists in the Google Play store.
280
     *
281
     * @param string|Model\AppId $appId application ID (Android package name) as
282
     *                                  a string or {@see Model\AppId} object
283
     *
284
     * @return bool returns `true` if the application exists, or `false` if not
285
     *
286
     * @api
287
     */
288 5
    public function existsApp($appId): bool
289
    {
290 5
        $appId = Util\Caster::castToAppId($appId, $this->defaultLocale, $this->defaultCountry);
291 5
        $fullUrl = $appId->getFullUrl();
292 5
        $psrRequest = new PsrRequest('HEAD', $fullUrl);
293 5
        $request = new Request($psrRequest, [
294
            RequestOptions::HTTP_ERRORS => false,
295 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

295
        ], /** @scrutinizer ignore-type */ new Scraper\ExistsAppScraper());
Loading history...
296
297
        try {
298 5
            return (bool) $this->getHttpClient()->request($request);
299
        } catch (\Throwable $e) {
300
            return false;
301
        }
302
    }
303
304
    /**
305
     * Checks if the specified applications exist in the Google Play store.
306
     * HTTP requests are executed in parallel.
307
     *
308
     * @param string[]|Model\AppId[] $appIds Array of application identifiers.
309
     *                                       The keys of the returned array correspond to the transferred array.
310
     *
311
     * @throws Exception\GooglePlayException if an HTTP error other than 404 is received
312
     *
313
     * @return bool[] An array of information about the existence of each
314
     *                application in the store Google Play. The keys of the returned
315
     *                array matches to the passed array.
316
     *
317
     * @see GPlayApps::setConcurrency() Sets the limit of concurrent HTTP requests.
318
     *
319
     * @api
320
     */
321 1
    public function existsApps(array $appIds): array
322
    {
323 1
        if (empty($appIds)) {
324
            return [];
325
        }
326
327 1
        $parseHandler = new Scraper\ExistsAppScraper();
328 1
        $requests = array_map(function ($appId) use ($parseHandler) {
329 1
            $fullUrl = Util\Caster::castToAppId($appId, $this->defaultLocale, $this->defaultCountry)->getFullUrl();
330 1
            $psrRequest = new PsrRequest('HEAD', $fullUrl);
331
332 1
            return new Request($psrRequest, [
333
                RequestOptions::HTTP_ERRORS => false,
334
            ], $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

334
            ], /** @scrutinizer ignore-type */ $parseHandler);
Loading history...
335
        }, $appIds);
336
337
        try {
338 1
            return $this->getHttpClient()->requestPool($requests);
339
        } catch (\Throwable $e) {
340
            throw new Exception\GooglePlayException($e->getMessage(), 1, $e);
341
        }
342
    }
343
344
    /**
345
     * Returns reviews of the Android app in the Google Play store.
346
     *
347
     * Getting a lot of reviews can take a lot of time.
348
     *
349
     * @param string|Model\AppId $appId application ID (Android package name) as
350
     *                                  a string or {@see Model\AppId} object
351
     * @param int                $limit Maximum number of reviews. To extract all
352
     *                                  reviews, use {@see GPlayApps::UNLIMIT}.
353
     * @param Enum\SortEnum|null $sort  Sort reviews of the application.
354
     *                                  If null, then sort by the newest reviews.
355
     *
356
     * @throws Exception\GooglePlayException if the application is not exists or other HTTP error
357
     *
358
     * @return Model\Review[] app reviews
359
     *
360
     * @see Enum\SortEnum Contains all valid values for the "sort" parameter.
361
     * @see GPlayApps::UNLIMIT Limit for all available results.
362
     *
363
     * @api
364
     */
365 1
    public function getReviews($appId, int $limit = 100, ?Enum\SortEnum $sort = null): array
366
    {
367 1
        $appId = Util\Caster::castToAppId($appId, $this->defaultLocale, $this->defaultCountry);
368 1
        $sort = $sort ?? Enum\SortEnum::NEWEST();
369
370 1
        $allCount = 0;
371 1
        $token = null;
372 1
        $allReviews = [];
373
374 1
        $cacheTtl = $sort === Enum\SortEnum::NEWEST()
375 1
            ? \DateInterval::createFromDateString('1 min')
376 1
            : \DateInterval::createFromDateString('1 hour');
377
378
        try {
379
            do {
380 1
                $count = $limit === self::UNLIMIT
381
                    ? Scraper\PlayStoreUiRequest::LIMIT_REVIEW_ON_PAGE
382 1
                    : min(Scraper\PlayStoreUiRequest::LIMIT_REVIEW_ON_PAGE, max($limit - $allCount, 1));
383
384 1
                $psrRequest = Scraper\PlayStoreUiRequest::getReviewsRequest($appId, $count, $sort, $token);
385 1
                $request = new Request($psrRequest, [
386
                    'cache_ttl' => $cacheTtl,
387 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

387
                ], /** @scrutinizer ignore-type */ new Scraper\ReviewsScraper($appId));
Loading history...
388 1
                [$reviews, $token] = $this->getHttpClient()->request($request);
389 1
                $allCount += \count($reviews);
390 1
                $allReviews[] = $reviews;
391 1
            } while ($token !== null && ($limit === self::UNLIMIT || $allCount < $limit));
392
        } catch (\Throwable $e) {
393
            throw new Exception\GooglePlayException($e->getMessage(), 1, $e);
394
        }
395
396 1
        $reviews = empty($allReviews) ? $allReviews : array_merge(...$allReviews);
397 1
        if ($limit !== self::UNLIMIT) {
398 1
            $reviews = \array_slice($reviews, 0, $limit);
399
        }
400
401 1
        return $reviews;
402
    }
403
404
    /**
405
     * @deprecated Feature no longer available
406
     *
407
     * @param mixed  $appId
408
     * @param string $reviewId
409
     *
410
     * @throws \Nelexa\GPlay\Exception\GooglePlayException
411
     */
412
    public function getReviewById($appId, string $reviewId): Model\Review
413
    {
414
        throw new Exception\GooglePlayException('Feature no longer available', 0);
415
    }
416
417
    /**
418
     * Returns a list of permissions for the application.
419
     *
420
     * @param string|Model\AppId $appId application ID (Android package name) as
421
     *                                  a string or {@see Model\AppId} object
422
     *
423
     * @throws Exception\GooglePlayException if the application is not exists or other HTTP error
424
     *
425
     * @return Model\Permission[] an array of permissions for the application
426
     *
427
     * @api
428
     */
429 1
    public function getPermissions($appId): array
430
    {
431 1
        $appId = Util\Caster::castToAppId($appId, $this->defaultLocale, $this->defaultCountry);
432
433
        try {
434 1
            $psrRequest = Scraper\PlayStoreUiRequest::getPermissionsRequest($appId);
435
436 1
            return $this->getHttpClient()->request(
437 1
                new Request(
438
                    $psrRequest,
439 1
                    [],
440 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

440
                    /** @scrutinizer ignore-type */ new Scraper\PermissionScraper()
Loading history...
441
                )
442
            );
443
        } catch (\Throwable $e) {
444
            throw new Exception\GooglePlayException($e->getMessage(), 1, $e);
445
        }
446
    }
447
448
    /**
449
     * Returns an array of application categories from the Google Play store.
450
     *
451
     * @return Model\Category[] array of application categories
452
     *
453
     * @api
454
     */
455 1
    public function getCategories(): array
456
    {
457 1
        return array_map(static function (CategoryEnum $category) {
458 1
            $categoryName = $category->value();
459 1
            $categoryName = str_replace('_', ' ', $categoryName);
460 1
            $categoryName = ucfirst(strtolower($categoryName));
461 1
            $categoryName = str_replace(' and ', ' & ', $categoryName);
462
463 1
            return new Category($category->name(), $categoryName);
464 1
        }, CategoryEnum::values());
465
    }
466
467
    /**
468
     * Returns information about the developer: name, icon, cover, description and website address.
469
     *
470
     * @param string|Model\Developer|Model\App $developerId developer id as
471
     *                                                      string, {@see Model\Developer}
472
     *                                                      or {@see Model\App} object
473
     *
474
     * @throws Exception\GooglePlayException if HTTP error is received
475
     *
476
     * @return Model\Developer information about the application developer
477
     *
478
     * @see GPlayApps::getDeveloperInfoForLocales() Returns information about the developer for the locale array.
479
     *
480
     * @api
481
     */
482 5
    public function getDeveloperInfo($developerId): Model\Developer
483
    {
484 5
        $developerId = Util\Caster::castToDeveloperId($developerId);
485
486 5
        if (!is_numeric($developerId)) {
487 3
            throw new Exception\GooglePlayException(
488 3
                sprintf(
489
                    'Developer "%s" does not have a personalized page on Google Play.',
490
                    $developerId
491
                )
492
            );
493
        }
494
495 2
        $url = self::GOOGLE_PLAY_APPS_URL . '/dev?' . http_build_query([
496
            self::REQ_PARAM_ID => $developerId,
497 2
            self::REQ_PARAM_LOCALE => $this->defaultLocale,
498
        ]);
499
500
        try {
501 2
            return $this->getHttpClient()->request(
502 2
                new Request(
503 2
                    new PsrRequest('GET', $url),
504 2
                    [],
505 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

505
                    /** @scrutinizer ignore-type */ new Scraper\DeveloperInfoScraper()
Loading history...
506
                )
507
            );
508 1
        } catch (\Throwable $e) {
509 1
            throw new Exception\GooglePlayException($e->getMessage(), 1, $e);
510
        }
511
    }
512
513
    /**
514
     * Returns information about the developer for the specified locales.
515
     *
516
     * @param string|Model\Developer|Model\App $developerId developer id as
517
     *                                                      string, {@see Model\Developer}
518
     *                                                      or {@see Model\App} object
519
     * @param string[]                         $locales     array of locales
520
     *
521
     * @throws Exception\GooglePlayException if HTTP error is received
522
     *
523
     * @return Model\Developer[] an array with information about the application developer
524
     *                           for each requested locale
525
     *
526
     * @see GPlayApps::setConcurrency() Sets the limit of concurrent HTTP requests.
527
     * @see GPlayApps::getDeveloperInfo() Returns information about the developer: name,
528
     *     icon, cover, description and website address.
529
     *
530
     * @api
531
     */
532 1
    public function getDeveloperInfoForLocales($developerId, array $locales = []): array
533
    {
534 1
        if (empty($locales)) {
535
            return [];
536
        }
537 1
        $locales = Util\LocaleHelper::getNormalizeLocales($locales);
538
539 1
        $id = Util\Caster::castToDeveloperId($developerId);
540
541 1
        if (!is_numeric($id)) {
542
            throw new Exception\GooglePlayException(
543
                sprintf(
544
                    'Developer "%s" does not have a personalized page on Google Play.',
545
                    $id
546
                )
547
            );
548
        }
549
550 1
        $requests = [];
551 1
        $url = self::GOOGLE_PLAY_APPS_URL . '/dev';
552 1
        $parseHandler = new Scraper\DeveloperInfoScraper();
553
554 1
        foreach ($locales as $locale) {
555 1
            $requestUrl = $url . '?' . http_build_query(
556
                [
557 1
                    self::REQ_PARAM_ID => $id,
558
                    self::REQ_PARAM_LOCALE => $locale,
559
                ]
560
            );
561 1
            $requests[$locale] = new Request(
562 1
                new PsrRequest('GET', $requestUrl),
563 1
                [],
564
                $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

564
                /** @scrutinizer ignore-type */ $parseHandler
Loading history...
565
            );
566
        }
567
568
        try {
569 1
            return $this->getHttpClient()->requestPool($requests);
570
        } catch (\Throwable $e) {
571
            throw new Exception\GooglePlayException($e->getMessage(), 1, $e);
572
        }
573
    }
574
575
    /**
576
     * Returns an array of applications from the Google Play store by developer id.
577
     *
578
     * @param string|Model\Developer|Model\App $developerId developer id as
579
     *                                                      string, {@see Model\Developer}
580
     *                                                      or {@see Model\App} object
581
     *
582
     * @throws Exception\GooglePlayException if HTTP error is received
583
     *
584
     * @return Model\App[] an array of applications with basic information
585
     *
586
     * @api
587
     */
588 1
    public function getDeveloperApps($developerId): array
589
    {
590 1
        $developerId = Util\Caster::castToDeveloperId($developerId);
591
592
        $query = [
593
            self::REQ_PARAM_ID => $developerId,
594 1
            self::REQ_PARAM_LOCALE => $this->defaultLocale,
595 1
            self::REQ_PARAM_COUNTRY => $this->defaultCountry,
596
        ];
597
598 1
        if (is_numeric($developerId)) {
599 1
            $developerUrl = self::GOOGLE_PLAY_APPS_URL . '/dev?' . http_build_query($query);
600
601
            try {
602
                /**
603
                 * @var string|null $developerUrl
604
                 */
605 1
                $developerUrl = $this->getHttpClient()->request(
606 1
                    new Request(
607 1
                        new PsrRequest('GET', $developerUrl),
608 1
                        [],
609 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

609
                        /** @scrutinizer ignore-type */ new Scraper\FindDevAppsUrlScraper()
Loading history...
610
                    )
611
                );
612
613 1
                if ($developerUrl === null) {
614
                    return [];
615
                }
616
617 1
                $developerUrl .= '&' . self::REQ_PARAM_LOCALE . '=' . urlencode($this->defaultLocale)
618 1
                    . '&' . self::REQ_PARAM_COUNTRY . '=' . urlencode($this->defaultCountry);
619
            } catch (\Throwable $e) {
620 1
                throw new Exception\GooglePlayException($e->getMessage(), 1, $e);
621
            }
622
        } else {
623 1
            $developerUrl = self::GOOGLE_PLAY_APPS_URL . '/developer?' . http_build_query($query);
624
        }
625
626 1
        return $this->fetchAppsFromClusterPage(
627
            $developerUrl,
628
            self::UNLIMIT
629
        );
630
    }
631
632
    /**
633
     * Returns an iterator of applications from the Google Play store for the specified cluster page.
634
     *
635
     * @param string $clusterPageUrl cluster page url
636
     *
637
     * @throws \Nelexa\GPlay\Exception\GooglePlayException
638
     *
639
     * @return \Generator<Model\App> an iterator with basic information about applications
640
     */
641 9
    public function getClusterApps(string $clusterPageUrl): \Generator
642
    {
643 9
        $clusterUri = new Uri($clusterPageUrl);
644 9
        $query = Query::parse($clusterUri->getQuery());
645
646 9
        if (!isset($query[self::REQ_PARAM_LOCALE])) {
647 5
            $query[self::REQ_PARAM_LOCALE] = $this->defaultLocale;
648
        }
649
650 9
        if (!isset($query[self::REQ_PARAM_COUNTRY])) {
651 6
            $query[self::REQ_PARAM_COUNTRY] = $this->defaultCountry;
652
        }
653
654 9
        $clusterUri = $clusterUri->withQuery(Query::build($query));
655 9
        $clusterPageUrl = (string) $clusterUri;
656
657
        try {
658 9
            [$apps, $token] = $this->getHttpClient()->request(
659 9
                new Request(
660 9
                    new PsrRequest('GET', $clusterPageUrl),
661 9
                    [],
662 9
                    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

662
                    /** @scrutinizer ignore-type */ new Scraper\ClusterAppsScraper()
Loading history...
663
                )
664
            );
665
666 9
            foreach ($apps as $app) {
667 9
                yield $app;
668
            }
669
670 9
            while ($token !== null) {
671 8
                $request = Scraper\PlayStoreUiRequest::getAppsRequest(
672 8
                    $query[self::REQ_PARAM_LOCALE],
673 8
                    $query[self::REQ_PARAM_COUNTRY],
674
                    Scraper\PlayStoreUiRequest::LIMIT_APPS_ON_PAGE,
675
                    $token
676
                );
677
678 8
                [$apps, $token] = $this->getHttpClient()->request(
679 8
                    new Request(
680
                        $request,
681 8
                        [],
682 8
                        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

682
                        /** @scrutinizer ignore-type */ new Scraper\PlayStoreUiAppsScraper()
Loading history...
683
                    )
684
                );
685
686 8
                foreach ($apps as $app) {
687 8
                    yield $app;
688
                }
689
            }
690
        } catch (\Throwable $e) {
691
            throw new Exception\GooglePlayException($e->getMessage(), 1, $e);
692
        }
693
    }
694
695
    /**
696
     * Returns a list of applications with basic information.
697
     *
698
     * @param string $clusterPageUrl cluster page URL
699
     * @param int    $limit          Maximum number of applications. To extract all
700
     *                               applications, use {@see GPlayApps::UNLIMIT}.
701
     *
702
     * @throws Exception\GooglePlayException if the application is not exists or other HTTP error
703
     *
704
     * @return Model\App[] array of applications with basic information about them
705
     *
706
     * @see GPlayApps::UNLIMIT Limit for all available results.
707
     */
708 2
    protected function fetchAppsFromClusterPage(
709
        string $clusterPageUrl,
710
        int $limit
711
    ): array {
712 2
        $apps = [];
713 2
        $count = 0;
714
715 2
        foreach ($this->getClusterApps($clusterPageUrl) as $app) {
716 2
            $apps[] = $app;
717 2
            ++$count;
718 2
            if ($count === $limit) {
719
                break;
720
            }
721
        }
722
723 2
        return $apps;
724
    }
725
726
    /**
727
     * Returns an array of similar applications with basic information about
728
     * them in the Google Play store.
729
     *
730
     * @param string|Model\AppId $appId application ID (Android package name)
731
     *                                  as a string or {@see Model\AppId} object
732
     * @param int                $limit The maximum number of similar applications.
733
     *                                  To extract all similar applications,
734
     *                                  use {@see GPlayApps::UNLIMIT}.
735
     *
736
     * @throws Exception\GooglePlayException if the application is not exists or other HTTP error
737
     *
738
     * @return Model\App[] an array of applications with basic information about them
739
     *
740
     * @see GPlayApps::UNLIMIT Limit for all available results.
741
     *
742
     * @api
743
     */
744 1
    public function getSimilarApps($appId, int $limit = 50): array
745
    {
746 1
        $appId = Util\Caster::castToAppId($appId, $this->defaultLocale, $this->defaultCountry);
747
748
        try {
749
            /** @var string|null $similarAppsUrl */
750 1
            $similarAppsUrl = $this->getHttpClient()->request(
751 1
                new Request(
752 1
                    new PsrRequest('GET', $appId->getFullUrl()),
753 1
                    [],
754 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

754
                    /** @scrutinizer ignore-type */ new Scraper\FindSimilarAppsUrlScraper($appId)
Loading history...
755
                )
756
            );
757
758 1
            if ($similarAppsUrl === null) {
759
                return [];
760
            }
761
762 1
            return $this->fetchAppsFromClusterPage(
763
                $similarAppsUrl,
764
                $limit
765
            );
766
        } catch (\Throwable $e) {
767
            throw new Exception\GooglePlayException($e->getMessage(), 1, $e);
768
        }
769
    }
770
771
    /**
772
     * Returns an iterator of cluster pages.
773
     *
774
     * @param string|Model\Category|Enum\CategoryEnum|null $category application category as
775
     *                                                               string, {@see Model\Category},
776
     *                                                               {@see Enum\CategoryEnum} or
777
     *                                                               `null` for all categories
778
     * @param Enum\AgeEnum|null                            $age      age limit or `null` for no limit
779
     *
780
     * @throws \Nelexa\GPlay\Exception\GooglePlayException
781
     *
782
     * @return \Generator<Model\ClusterPage> an iterator of cluster pages
783
     */
784 8
    public function getClusterPages($category = null, ?Enum\AgeEnum $age = null): \Generator
785
    {
786
        $queryParams = [
787 8
            self::REQ_PARAM_LOCALE => $this->defaultLocale,
788 8
            self::REQ_PARAM_COUNTRY => $this->defaultCountry,
789
        ];
790
791 8
        if ($age !== null) {
792
            $queryParams['age'] = $age->value();
793
        }
794
795 8
        $url = self::GOOGLE_PLAY_APPS_URL;
796
797 8
        if ($category !== null) {
798 6
            $url .= '/category/' . Util\Caster::castToCategoryId($category);
799
        }
800 8
        $url .= '?' . http_build_query($queryParams);
801
802 8
        ['results' => $results, 'token' => $token] = $this->getHttpClient()->request(
803 8
            new Request(
804 8
                new PsrRequest('GET', $url),
805 8
                [],
806 8
                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

806
                /** @scrutinizer ignore-type */ new Scraper\ClusterPagesFromListAppsScraper()
Loading history...
807
            )
808
        );
809
810 8
        foreach ($results as $result) {
811 8
            yield $result;
812
        }
813
814 8
        while ($token !== null) {
815
            try {
816 5
                $psrRequest = Scraper\PlayStoreUiRequest::getClusterPagesRequest(
817
                    $token,
818 5
                    $this->defaultLocale,
819 5
                    $this->defaultCountry
820
                );
821
822 5
                ['results' => $results, 'token' => $token] = $this->getHttpClient()->request(
823 5
                    new Request(
824
                        $psrRequest,
825 5
                        [],
826 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

826
                        /** @scrutinizer ignore-type */ new Scraper\ClusterPagesFromClusterResponseScraper()
Loading history...
827
                    )
828
                );
829
830 5
                foreach ($results as $result) {
831 5
                    yield $result;
832
                }
833
            } catch (\Throwable $e) {
834
                throw new Exception\GooglePlayException($e->getMessage(), 1, $e);
835
            }
836
        }
837
    }
838
839
    /**
840
     * Returns the Google Play search suggests.
841
     *
842
     * @param string $query search query
843
     *
844
     * @throws Exception\GooglePlayException if HTTP error is received
845
     *
846
     * @return string[] array containing search suggestions
847
     *
848
     * @api
849
     */
850 2
    public function getSearchSuggestions(string $query): array
851
    {
852 2
        $query = trim($query);
853
854 2
        if ($query === '') {
855
            return [];
856
        }
857
858
        try {
859 2
            $psrRequest = Scraper\PlayStoreUiRequest::getSuggestRequest(
860
                $query,
861 2
                $this->defaultLocale,
862 2
                $this->defaultCountry
863
            );
864
865
            /** @var string[] $suggestions */
866 2
            $suggestions = $this->getHttpClient()->request(
867 2
                new Request(
868
                    $psrRequest,
869 2
                    [],
870 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

870
                    /** @scrutinizer ignore-type */ new Scraper\SuggestScraper()
Loading history...
871
                )
872
            );
873
        } catch (\Throwable $e) {
874
            throw new Exception\GooglePlayException($e->getMessage(), 1, $e);
875
        }
876
877 2
        return $suggestions;
878
    }
879
880
    /**
881
     * Returns a list of applications from the Google Play store for a search query.
882
     *
883
     * @param string              $query search query
884
     * @param int                 $limit the limit on the number of search results
885
     * @param Enum\PriceEnum|null $price price category or `null`
886
     *
887
     * @throws Exception\GooglePlayException if HTTP error is received
888
     *
889
     * @return Model\App[] an array of applications with basic information
890
     *
891
     * @see Enum\PriceEnum Contains all valid values for the "price" parameter.
892
     * @see GPlayApps::UNLIMIT Limit for all available results.
893
     *
894
     * @api
895
     */
896 1
    public function search(string $query, int $limit = 50, ?Enum\PriceEnum $price = null): array
897
    {
898 1
        $query = trim($query);
899
900 1
        if (empty($query)) {
901
            throw new \InvalidArgumentException('Search query missing');
902
        }
903 1
        $price = $price ?? Enum\PriceEnum::ALL();
904
905
        $params = [
906
            'c' => 'apps',
907
            'q' => $query,
908 1
            self::REQ_PARAM_LOCALE => $this->defaultLocale,
909 1
            self::REQ_PARAM_COUNTRY => $this->defaultCountry,
910 1
            'price' => $price->value(),
911
        ];
912 1
        $clusterPageUrl = self::GOOGLE_PLAY_URL . '/store/search?' . http_build_query($params);
913
914 1
        $apps = [];
915 1
        $count = 0;
916
917 1
        foreach ($this->getClusterApps($clusterPageUrl) as $app) {
918 1
            $apps[] = $app;
919 1
            ++$count;
920 1
            if ($count === $limit) {
921
                break;
922
            }
923
        }
924
925 1
        return $apps;
926
    }
927
928
    /**
929
     * Returns an array of applications from the Google Play store for the specified category.
930
     *
931
     * @param string|Model\Category|Enum\CategoryEnum|null $category application category as
932
     *                                                               string, {@see Model\Category},
933
     *                                                               {@see Enum\CategoryEnum} or
934
     *                                                               `null` for all categories
935
     * @param Enum\AgeEnum|null                            $age      age limit or null for no limit
936
     * @param int                                          $limit    limit on the number of results
937
     *                                                               or {@see GPlayApps::UNLIMIT}
938
     *                                                               for no limit
939
     *
940
     * @throws \Nelexa\GPlay\Exception\GooglePlayException
941
     *
942
     * @return Model\App[] an array of applications with basic information
943
     *
944
     * @api
945
     *
946
     * @see GPlayApps::UNLIMIT Limit for all available results.
947
     */
948 5
    public function getListApps($category = null, ?Enum\AgeEnum $age = null, int $limit = self::UNLIMIT): array
949
    {
950 5
        return $this->fetchAppsFromClusterPages($category, $age, $limit);
951
    }
952
953
    /**
954
     * Returns an array of **top apps** from the Google Play store for the specified category.
955
     *
956
     * @param string|Model\Category|Enum\CategoryEnum|null $category application category as
957
     *                                                               string, {@see Model\Category},
958
     *                                                               {@see Enum\CategoryEnum} or
959
     *                                                               `null` for all categories
960
     * @param int                                          $limit    limit on the number of results
961
     *                                                               or {@see GPlayApps::UNLIMIT}
962
     *                                                               for no limit
963
     * @param Enum\AgeEnum|null                            $age
964
     *
965
     * @throws \Nelexa\GPlay\Exception\GooglePlayException
966
     *
967
     * @return Model\App[] an array of applications with basic information
968
     *
969
     * @api
970
     *
971
     * @see GPlayApps::UNLIMIT Limit for all available results.
972
     * @deprecated Use {@see \Nelexa\GPlay\GPlayApps::getTopSellingFreeApps}, {@see \Nelexa\GPlay\GPlayApps::getTopSellingPaidApps} and {@see \Nelexa\GPlay\GPlayApps::getTopGrossingApps}
973
     */
974
    public function getTopApps($category = null, ?Enum\AgeEnum $age = null, int $limit = self::UNLIMIT): array
975
    {
976
        return $this->getTopSellingFreeApps($category, $limit);
977
    }
978
979
    /**
980
     * Returns an array of **top selling free apps** from the Google Play store for the specified category.
981
     *
982
     * @param string|Model\Category|Enum\CategoryEnum $category application category as string, {@see Model\Category}, {@see Enum\CategoryEnum}, ex. APPLICATION or GAME
983
     * @param int                                     $limit    Limit
984
     *
985
     * @throws \Nelexa\GPlay\Exception\GooglePlayException
986
     *
987
     * @return Model\App[] App list
988
     */
989 4
    public function getTopSellingFreeApps($category = 'APPLICATION', int $limit = 500): array
990
    {
991 4
        return $this->fetchTopApps($category ?? 'APPLICATION', 'topselling_free', $limit);
992
    }
993
994
    /**
995
     * Returns an array of **top selling paid apps** from the Google Play store for the specified category.
996
     *
997
     * @param string|Model\Category|Enum\CategoryEnum $category application category as string, {@see Model\Category}, {@see Enum\CategoryEnum}, ex. APPLICATION or GAME
998
     * @param int                                     $limit    Limit
999
     *
1000
     * @throws \Nelexa\GPlay\Exception\GooglePlayException
1001
     *
1002
     * @return Model\App[] App list
1003
     */
1004 4
    public function getTopSellingPaidApps($category = 'APPLICATION', int $limit = 500): array
1005
    {
1006 4
        return $this->fetchTopApps($category, 'topselling_paid', $limit);
1007
    }
1008
1009
    /**
1010
     * Returns an array of **top grossing apps** from the Google Play store for the specified category.
1011
     *
1012
     * @param string|Model\Category|Enum\CategoryEnum $category application category as string, {@see Model\Category}, {@see Enum\CategoryEnum}, ex. APPLICATION or GAME
1013
     * @param int                                     $limit    Limit
1014
     *
1015
     * @throws \Nelexa\GPlay\Exception\GooglePlayException
1016
     *
1017
     * @return Model\App[] App list
1018
     */
1019 4
    public function getTopGrossingApps($category = 'APPLICATION', int $limit = 500): array
1020
    {
1021 4
        return $this->fetchTopApps($category, 'topgrossing', $limit);
1022
    }
1023
1024
    /**
1025
     * @param string|Model\Category|Enum\CategoryEnum $category
1026
     * @param string                                  $topSlug
1027
     * @param int                                     $limit
1028
     *
1029
     * @throws Exception\GooglePlayException
1030
     *
1031
     * @return Model\App[]
1032
     */
1033 12
    protected function fetchTopApps($category, string $topSlug, int $limit = 1000): array
1034
    {
1035
        try {
1036 12
            $psrRequest = Scraper\PlayStoreUiRequest::getTopCategoryApps(
1037
                $topSlug,
1038 12
                Util\Caster::castToCategoryId($category),
1039 12
                $this->defaultLocale,
1040 12
                $this->defaultCountry,
1041
                $limit
1042
            );
1043
1044 12
            return $this->getHttpClient()->request(
1045 12
                new Request(
1046
                    $psrRequest,
1047 12
                    [],
1048 12
                    new Scraper\CategoryTopScraper()
0 ignored issues
show
Bug introduced by
new Nelexa\GPlay\Scraper\CategoryTopScraper() of type Nelexa\GPlay\Scraper\CategoryTopScraper 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

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