Passed
Branch develop (610403)
by Alexey
02:26
created

GPlayApps::castToDeveloperId()   A

Complexity

Conditions 4
Paths 4

Size

Total Lines 12
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Importance

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