Passed
Branch develop (61f351)
by Alexey
01:52
created

GPlayApps::getAppsFromClusterPage()   B

Complexity

Conditions 7
Paths 20

Size

Total Lines 38
Code Lines 20

Duplication

Lines 0
Ratio 0 %

Importance

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