Completed
Branch develop (45165d)
by Alexey
04:09
created

GPlayApps.php$0 ➔ saveGoogleImages()   C

Complexity

Conditions 11

Size

Total Lines 61

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 61
rs 6.7042
c 0
b 0
f 0
cc 11

How to fix   Long Method    Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

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
            $requestApp = new RequestApp($requestApp, $this->defaultLocale, $this->defaultCountry);
258
        } elseif ($requestApp instanceof App) {
259
            $requestApp = new RequestApp($requestApp->getId(), $requestApp->getLocale(), $this->defaultCountry);
260
        } elseif (!$requestApp instanceof RequestApp) {
0 ignored issues
show
introduced by
$requestApp is always a sub-type of Nelexa\GPlay\Request\RequestApp.
Loading history...
261
            throw new \InvalidArgumentException('unsupport argument type');
262
        }
263
        return $requestApp;
264
    }
265
266
    /**
267
     * Returns detailed information about the application in all
268
     * available locales. HTTP requests are executed in parallel.
269
     *
270
     * @param string|RequestApp|App $requestApp Application id (package name)
271
     *     or object {@see RequestApp} or object {@see App}.
272
     * @return AppDetail[] An array with detailed information about the application on all available locales. The array
273
     *     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 getAppInAvailableLocales($requestApp): array
278
    {
279
        $requestApp = $this->castToRequestApp($requestApp);
280
281
        $requests = [];
282
        foreach (LocaleHelper::SUPPORTED_LOCALES as $locale) {
283
            $requests[$locale] = new RequestApp($requestApp->getId(), $locale, $this->defaultCountry);
284
        }
285
        $list = $this->getApps($requests);
286
287
        $preferredLocale = null;
288
        foreach ($list as $app) {
289
            if ($app->getTranslatedFromLanguage() !== null) {
290
                $preferredLocale = $app->getTranslatedFromLanguage();
291
                break;
292
            }
293
        }
294
        if ($preferredLocale === null) {
295
            $preferredLocale = $list[self::DEFAULT_LOCALE];
296
        }
297
298
        $preferredApp = $list[$preferredLocale];
299
        foreach ($list as $locale => $app) {
300
            if ($preferredApp->getLocale() !== $locale && $preferredApp->equals($app)) {
301
                // delete data with google translate machine translation
302
                unset($list[$locale]);
303
            }
304
        }
305
306
        foreach ($list as $locale => $app) {
307
            if (($pos = strpos($locale, '_')) !== false) {
308
                $rootLang = substr($locale, 0, $pos);
309
                $rootLangLocale = LocaleHelper::getNormalizeLocale($rootLang);
310
                if (
311
                    $rootLangLocale !== $locale &&
312
                    isset($list[$rootLangLocale]) &&
313
                    $list[$rootLangLocale]->equals($app)
314
                ) {
315
                    // delete duplicate data,
316
                    // for example, delete en_CA, en_IN, en_GB, en_ZA, if there is en_US and they are equals.
317
                    unset($list[$locale]);
318
                }
319
            }
320
        }
321
322
        // sorting array keys; the first key is the preferred locale
323
        uksort(
324
            $list,
325
            static function (
326
                /** @noinspection PhpUnusedParameterInspection */
327
                string $a,
328
                string $b
329
            ) use ($preferredLocale) {
330
                return $b === $preferredLocale ? 1 : 0;
331
            }
332
        );
333
334
        return $list;
335
    }
336
337
    /**
338
     * Checks if the specified application exists in the Google Play Store.
339
     *
340
     * @param string|RequestApp|App $requestApp Application id (package name)
341
     *     or object {@see RequestApp} or object {@see App}.
342
     * @return bool true if the application exists on the Google Play store, and false if not.
343
     */
344
    public function existsApp($requestApp): bool
345
    {
346
        $requestApp = $this->castToRequestApp($requestApp);
347
        $url = self::GOOGLE_PLAY_APPS_URL . '/details';
348
349
        try {
350
            return (bool)$this->getHttpClient()->request('HEAD', $url, [
351
                RequestOptions::QUERY => [
352
                    self::REQ_PARAM_APP_ID => $requestApp->getId(),
353
                    self::REQ_PARAM_LOCALE => $requestApp->getLocale(),
354
                    self::REQ_PARAM_COUNTRY => $requestApp->getCountry(),
355
                ],
356
                RequestOptions::HTTP_ERRORS => false,
357
                HttpClient::OPTION_HANDLER_RESPONSE => new ExistsAppScraper(),
358
            ]);
359
        } catch (GuzzleException $e) {
360
            return false;
361
        }
362
    }
363
364
    /**
365
     * Checks if the specified applications exist in the Google Play store.
366
     * HTTP requests are executed in parallel.
367
     *
368
     * @param string[]|RequestApp[]|App[] $requestApps array of application ids or array of {@see RequestApp} or array
369
     *     of {@see App}.
370
     * @return AppDetail[] Массив подробной информации для каждого приложения.
371
     * Ключи возвращаемого массива соответствуют переданному массиву.
372
     * @return bool[] An array of information about the existence of each application in the store Google Play. The
373
     *     keys of the returned array matches to the passed array.
374
     * @throws GooglePlayException
375
     * @see GPlayApps::setConcurrency() To set the limit of parallel requests
376
     */
377
    public function existsApps(array $requestApps): array
378
    {
379
        if (empty($requestApps)) {
380
            return [];
381
        }
382
        $urls = $this->getRequestAppsUrlList($requestApps);
383
        try {
384
            return $this->getHttpClient()->requestAsyncPool(
385
                'HEAD',
386
                $urls,
387
                [
388
                    RequestOptions::HTTP_ERRORS => false,
389
                    HttpClient::OPTION_HANDLER_RESPONSE => new ExistsAppScraper(),
390
                ],
391
                $this->concurrency
392
            );
393
        } catch (GuzzleException $e) {
394
            throw new GooglePlayException($e->getMessage(), $e->getCode(), $e);
395
        }
396
    }
397
398
    /**
399
     * Returns an array with application reviews.
400
     *
401
     * @param string|RequestApp|App $requestApp Application id (package name)
402
     *     or object {@see RequestApp} or object {@see App}.
403
     * @param int $page page number, starts with 0
404
     * @param SortEnum|null $sort Sort reviews of the application.
405
     *     If null, then sort by the newest reviews.
406
     * @return Review[] App reviews
407
     * @throws GooglePlayException if the application is not exists or other HTTP error
408
     *
409
     * @see SortEnum::NEWEST()       Sort by latest reviews.
410
     * @see SortEnum::HELPFULNESS()  Sort by helpful reviews.
411
     * @see SortEnum::RATING()       Sort by rating reviews.
412
     */
413
    public function getAppReviews($requestApp, int $page = 0, ?SortEnum $sort = null): array
414
    {
415
        $requestApp = $this->castToRequestApp($requestApp);
416
        $sort = $sort ?? SortEnum::NEWEST();
417
        $page = max(0, $page);
418
419
        $url = self::GOOGLE_PLAY_URL . '/store/getreviews';
420
        $formParams = [
421
            self::REQ_PARAM_APP_ID => $requestApp->getId(),
422
            self::REQ_PARAM_LOCALE => $requestApp->getLocale(),
423
            'pageNum' => $page,
424
            'reviewSortOrder' => $sort->value(),
425
            'reviewType' => 0,
426
            'xhr' => 1,
427
        ];
428
429
        try {
430
            return $this->getHttpClient()->request(
431
                'POST',
432
                $url,
433
                [
434
                    RequestOptions::FORM_PARAMS => $formParams,
435
                    HttpClient::OPTION_CACHE_TTL => \DateInterval::createFromDateString(SortEnum::NEWEST() ? '2 min' : '1 hour'),
436
                    HttpClient::OPTION_HANDLER_RESPONSE => new AppReviewScraper(),
437
                ]
438
            );
439
        } catch (GuzzleException $e) {
440
            throw new GooglePlayException($e->getMessage(), $e->getCode(), $e);
441
        }
442
    }
443
444
    /**
445
     * Get a list of all app reviews. The maximum number of reviews available
446
     * for extraction is about 4480 reviews. This can take a long time.
447
     *
448
     * @param string|RequestApp|App $requestApp Application id (package name)
449
     *     or object {@see RequestApp} or object {@see App}.
450
     * @param SortEnum|null $sort Sort reviews of the application.
451
     *     If null, then sort by the newest reviews.
452
     * @param int|null $limit limit reviews
453
     * @return Review[]
454
     *
455
     * @see SortEnum::NEWEST()       Sort by latest reviews.
456
     * @see SortEnum::HELPFULNESS()  Sort by helpful reviews.
457
     * @see SortEnum::RATING()       Sort by rating reviews.
458
     */
459
    public function getAppAllReviews(
460
        $requestApp,
461
        ?SortEnum $sort = null,
462
        ?int $limit = null
463
    ): array {
464
        $page = 0;
465
        $reviewsGroup = [];
466
        $count = 0;
467
        try {
468
            do {
469
                $reviewsOnPage = $this->getAppReviews($requestApp, $page, $sort);
470
                $countOnPage = count($reviewsOnPage);
471
                $count += $countOnPage;
472
                $reviewsGroup[] = $reviewsOnPage;
473
                $page++;
474
            } while (
475
                $countOnPage === /*google play limit on page*/ 40 &&
476
                $page < /*google play limit pages*/ 112 &&
477
                ($limit === null || $count < $limit)
478
            );
479
        } catch (\Throwable $e) {
480
            @trigger_error($e->getMessage(), E_USER_WARNING);
481
        }
482
        $reviews = [];
483
        if (!empty($reviewsGroup)) {
484
            $reviews = array_merge(...$reviewsGroup);
485
        }
486
        if ($limit !== null) {
487
            $reviews = array_slice($reviews, 0, $limit);
488
        }
489
        return $reviews;
490
    }
491
492
    /**
493
     * Returns a list of similar applications in the Google Play store.
494
     *
495
     * @param string|RequestApp|App $requestApp Application id (package name)
496
     *     or object {@see RequestApp} or object {@see App}.
497
     * @param int $limit limit of similar applications
498
     * @return App[] array of applications with basic information
499
     * @throws GooglePlayException if the application is not exists or other HTTP error
500
     */
501
    public function getSimilarApps($requestApp, int $limit = 50): array
502
    {
503
        $requestApp = $this->castToRequestApp($requestApp);
504
        $limit = max(1, min($limit, self::MAX_SEARCH_RESULTS));
505
        $params = [
506
            self::REQ_PARAM_APP_ID => $requestApp->getId(),
507
            self::REQ_PARAM_LOCALE => $requestApp->getLocale(),
508
            self::REQ_PARAM_COUNTRY => $requestApp->getCountry(),
509
        ];
510
        $url = self::GOOGLE_PLAY_APPS_URL . '/details?' . http_build_query($params);
511
        $httpClient = $this->getHttpClient();
512
        try {
513
            return $httpClient->request(
514
                'GET',
515
                $url,
516
                [
517
                    HttpClient::OPTION_HANDLER_RESPONSE => new SimilarAppsScraper(
518
                        $httpClient,
519
                        $limit,
520
                        $requestApp->getLocale(),
521
                        $requestApp->getCountry()
522
                    ),
523
                ]
524
            );
525
        } catch (GuzzleException $e) {
526
            throw new GooglePlayException($e->getMessage(), $e->getCode(), $e);
527
        }
528
    }
529
530
    /**
531
     * Returns a list of permissions for the application.
532
     *
533
     * @param string|RequestApp|App $requestApp Application id (package name)
534
     *     or object {@see RequestApp} or object {@see App}.
535
     * @return Permission[] list of permissions for the application
536
     * @throws GooglePlayException
537
     */
538
    public function getPermissions($requestApp): array
539
    {
540
        $requestApp = $this->castToRequestApp($requestApp);
541
542
        $url = self::GOOGLE_PLAY_URL . '/store/xhr/getdoc?authuser=0';
543
        try {
544
            return $this->getHttpClient()->request(
545
                'POST',
546
                $url,
547
                [
548
                    RequestOptions::FORM_PARAMS => [
549
                        'ids' => $requestApp->getId(),
550
                        self::REQ_PARAM_LOCALE => $requestApp->getLocale(),
551
                        'xhr' => 1,
552
                    ],
553
                    HttpClient::OPTION_HANDLER_RESPONSE => new PermissionScraper(),
554
                ]
555
            );
556
        } catch (GuzzleException $e) {
557
            throw new GooglePlayException($e->getMessage(), $e->getCode(), $e);
558
        }
559
    }
560
561
    /**
562
     * Returns a list of application categories from the Google Play store.
563
     *
564
     * @param string|null $locale site locale or default locale used
565
     * @return Category[] list of application categories
566
     * @throws GooglePlayException caused by HTTP error
567
     */
568
    public function getCategories(?string $locale = null): array
569
    {
570
        $locale = LocaleHelper::getNormalizeLocale($locale ?? $this->defaultLocale);
571
572
        $url = self::GOOGLE_PLAY_APPS_URL;
573
        try {
574
            return $this->getHttpClient()->request(
575
                'GET',
576
                $url,
577
                [
578
                    RequestOptions::QUERY => [
579
                        self::REQ_PARAM_LOCALE => $locale,
580
                    ],
581
                    HttpClient::OPTION_HANDLER_RESPONSE => new CategoriesScraper(),
582
                ]
583
            );
584
        } catch (GuzzleException $e) {
585
            throw new GooglePlayException($e->getMessage(), $e->getCode(), $e);
586
        }
587
    }
588
589
    /**
590
     * Returns a list of application categories from the Google Play store for the locale array.
591
     * HTTP requests are executed in parallel.
592
     *
593
     * @param string[] $locales array of locales
594
     * @return Category[][] list of application categories by locale
595
     * @throws GooglePlayException caused by HTTP error
596
     * @see GPlayApps::setConcurrency() To set the limit of parallel requests
597
     */
598
    public function getCategoriesInLocales(array $locales): array
599
    {
600
        if (empty($locales)) {
601
            return [];
602
        }
603
        $locales = LocaleHelper::getNormalizeLocales($locales);
604
605
        $urls = [];
606
        $url = self::GOOGLE_PLAY_APPS_URL;
607
        foreach ($locales as $locale) {
608
            $urls[$locale] = $url . '?' . http_build_query([
609
                    self::REQ_PARAM_LOCALE => $locale,
610
                ]);
611
        }
612
613
        try {
614
            return $this->getHttpClient()->requestAsyncPool(
615
                'GET',
616
                $urls,
617
                [
618
                    HttpClient::OPTION_HANDLER_RESPONSE => new CategoriesScraper(),
619
                ],
620
                $this->concurrency
621
            );
622
        } catch (GuzzleException $e) {
623
            throw new GooglePlayException($e->getMessage(), $e->getCode(), $e);
624
        }
625
    }
626
627
    /**
628
     * Returns a list of categories from the Google Play store for all available locales.
629
     *
630
     * @return Category[][] list of application categories by locale
631
     * @throws GooglePlayException caused by HTTP error
632
     * @see GPlayApps::setConcurrency() To set the limit of parallel requests
633
     */
634
    public function getCategoriesInAvailableLocales(): array
635
    {
636
        return $this->getCategoriesInLocales(LocaleHelper::SUPPORTED_LOCALES);
637
    }
638
639
    /**
640
     * Returns information about the developer: name, icon, cover, description and website address.
641
     *
642
     * @param string|int|Developer|App|AppDetail $developerId developer identifier or object containing it
643
     * @param string|null $locale site locale or default locale used
644
     * @return Developer information about the developer
645
     * @throws GooglePlayException caused by HTTP error
646
     */
647
    public function getDeveloperInfo($developerId, ?string $locale = null): Developer
648
    {
649
        $developerId = $this->caseToDeveloperId($developerId);
650
        if (!is_numeric($developerId)) {
651
            throw new GooglePlayException(sprintf('Developer "%s" does not have a personalized page on Google Play.', $developerId));
652
        }
653
654
        $locale = LocaleHelper::getNormalizeLocale($locale ?? $this->defaultLocale);
655
        $url = self::GOOGLE_PLAY_APPS_URL . '/dev';
656
        try {
657
            return $this->getHttpClient()->request(
658
                'GET',
659
                $url,
660
                [
661
                    RequestOptions::QUERY => [
662
                        self::REQ_PARAM_APP_ID => $developerId,
663
                        self::REQ_PARAM_LOCALE => $locale,
664
                    ],
665
                    HttpClient::OPTION_HANDLER_RESPONSE => new DeveloperPageScraper(),
666
                ]
667
            );
668
        } catch (GuzzleException $e) {
669
            throw new GooglePlayException($e->getMessage(), $e->getCode(), $e);
670
        }
671
    }
672
673
    /**
674
     * @param string|int|Developer|App|AppDetail $developerId
675
     * @return string
676
     */
677
    private function caseToDeveloperId($developerId): string
678
    {
679
        if (is_string($developerId)) {
680
            return $developerId;
681
        }
682
        if (is_int($developerId)) {
683
            return (string)$developerId;
684
        }
685
        if ($developerId instanceof App) {
686
            return $developerId->getDeveloper()->getId();
687
        }
688
        if ($developerId instanceof Developer) {
0 ignored issues
show
introduced by
$developerId is always a sub-type of Nelexa\GPlay\Model\Developer.
Loading history...
689
            return $developerId->getId();
690
        }
691
        throw new \InvalidArgumentException('The $developerId argument must contain the developer id or the application/developer object.');
692
    }
693
694
    /**
695
     * Returns information about the developer for the locale array.
696
     *
697
     * @param string|int|Developer|App|AppDetail $developerId developer identifier or object containing it
698
     * @param string[] $locales array of locales
699
     * @return Developer[] list of developer by locale
700
     * @throws GooglePlayException caused by HTTP error
701
     * @see GPlayApps::setConcurrency() To set the limit of parallel requests
702
     */
703
    public function getDeveloperInfoInLocales($developerId, array $locales = []): array
704
    {
705
        if (empty($locales)) {
706
            return [];
707
        }
708
        $locales = LocaleHelper::getNormalizeLocales($locales);
709
710
        $id = $this->caseToDeveloperId($developerId);
711
        if (!is_numeric($id)) {
712
            throw new GooglePlayException(sprintf('Developer "%s" does not have a personalized page on Google Play.', $id));
713
        }
714
715
        $urls = [];
716
        $url = self::GOOGLE_PLAY_APPS_URL . '/dev';
717
        foreach ($locales as $locale) {
718
            $urls[$locale] = $url . '?' . http_build_query([
719
                    self::REQ_PARAM_APP_ID => $id,
720
                    self::REQ_PARAM_LOCALE => $locale,
721
                ]);
722
        }
723
724
        try {
725
            return $this->getHttpClient()->requestAsyncPool(
726
                'GET',
727
                $urls,
728
                [
729
                    HttpClient::OPTION_HANDLER_RESPONSE => new DeveloperPageScraper(),
730
                ],
731
                $this->concurrency
732
            );
733
        } catch (GuzzleException $e) {
734
            throw new GooglePlayException($e->getMessage(), $e->getCode(), $e);
735
        }
736
    }
737
738
    /**
739
     * Returns information about the developer for all available locales.
740
     *
741
     * @param string|int|Developer|App|AppDetail $developerId developer identifier or object containing it
742
     * @return Developer[] list of developer by locale
743
     * @throws GooglePlayException caused by HTTP error
744
     * @see GPlayApps::setConcurrency() To set the limit of parallel requests
745
     */
746
    public function getDeveloperInfoInAvailableLocales(int $developerId): array
747
    {
748
        $list = $this->getDeveloperInfoInLocales($developerId, LocaleHelper::SUPPORTED_LOCALES);
749
750
        $preferredLocale = self::DEFAULT_LOCALE;
751
752
        $preferredInfo = $list[$preferredLocale];
753
        $list = array_filter($list, static function (Developer $info, string $locale) use ($preferredInfo, $preferredLocale) {
754
            return $locale === $preferredLocale || $preferredInfo->equals($info);
755
        }, ARRAY_FILTER_USE_BOTH);
756
757
        foreach ($list as $locale => $info) {
758
            if (($pos = strpos($locale, '_')) !== false) {
759
                $rootLang = substr($locale, 0, $pos);
760
                $rootLangLocale = LocaleHelper::getNormalizeLocale($rootLang);
761
                if (
762
                    $rootLangLocale !== $locale &&
763
                    isset($list[$rootLangLocale]) &&
764
                    $list[$rootLangLocale]->equals($info)
765
                ) {
766
                    // delete duplicate data,
767
                    // for example, delete en_CA, en_IN, en_GB, en_ZA, if there is en_US and they are equals.
768
                    unset($list[$locale]);
769
                }
770
            }
771
        }
772
773
        return $list;
774
    }
775
776
    /**
777
     * Returns a list of developer applications in the Google Play store.
778
     *
779
     * @param string|int|Developer|App|AppDetail $developerId developer identifier or object containing it
780
     * @param string|null $locale locale or default locale used
781
     * @param string|null $country country or default country used
782
     * @return App[] array of applications with basic information
783
     * @throws GooglePlayException caused by HTTP error
784
     */
785
    public function getDeveloperApps(
786
        $developerId,
787
        ?string $locale = null,
788
        ?string $country = null
789
    ): array {
790
        $locale = LocaleHelper::getNormalizeLocale($locale ?? $this->defaultLocale);
791
        $country = $country ?? $this->defaultCountry;
792
        $developerId = $this->caseToDeveloperId($developerId);
793
794
        $httpClient = $this->getHttpClient();
795
        $query = [
796
            self::REQ_PARAM_APP_ID => $developerId,
797
            self::REQ_PARAM_LOCALE => $locale,
798
            self::REQ_PARAM_COUNTRY => $country,
799
        ];
800
801
        try {
802
            if (is_numeric($developerId)) {
803
                $url = self::GOOGLE_PLAY_APPS_URL . '/dev';
804
                return $httpClient->request(
805
                    'GET',
806
                    $url,
807
                    [
808
                        RequestOptions::QUERY => $query,
809
                        HttpClient::OPTION_HANDLER_RESPONSE => new DeveloperAppsScraper(
810
                            $httpClient,
811
                            500,
812
                            $locale,
813
                            $country
814
                        ),
815
                    ]
816
                );
817
            }
818
819
            $url = self::GOOGLE_PLAY_APPS_URL . '/developer';
820
            return $httpClient->request(
821
                'GET',
822
                $url,
823
                [
824
                    RequestOptions::QUERY => $query,
825
                    HttpClient::OPTION_HANDLER_RESPONSE => new PlayStoreUiPagesScraper(
826
                        $httpClient,
827
                        500,
828
                        $locale,
829
                        $country
830
                    ),
831
                ]
832
            );
833
        } catch (GuzzleException $e) {
834
            throw new GooglePlayException($e->getMessage(), $e->getCode(), $e);
835
        }
836
    }
837
838
    /**
839
     * Returns the Google Play search suggests.
840
     *
841
     * @param string $query search query
842
     * @param string|null $locale locale or default locale used
843
     * @param string|null $country country or default country used
844
     * @return string[] array with search suggest
845
     * @throws GooglePlayException caused by HTTP error
846
     */
847
    public function getSuggest(
848
        string $query,
849
        ?string $locale = null,
850
        ?string $country = null
851
    ): array {
852
        $query = trim($query);
853
        if ($query === '') {
854
            return [];
855
        }
856
        $locale = LocaleHelper::getNormalizeLocale($locale ?? $this->defaultLocale);
857
        $country = $country ?? $this->defaultCountry;
858
859
        $url = 'https://market.android.com/suggest/SuggRequest';
860
        try {
861
            return $this->getHttpClient()->request(
862
                'GET',
863
                $url,
864
                [
865
                    RequestOptions::QUERY => [
866
                        'json' => 1,
867
                        'c' => 3,
868
                        'query' => $query,
869
                        self::REQ_PARAM_LOCALE => $locale,
870
                        self::REQ_PARAM_COUNTRY => $country,
871
                    ],
872
                    HttpClient:: OPTION_HANDLER_RESPONSE => new class implements ResponseHandlerInterface {
873
                        /**
874
                         * @param RequestInterface $request
875
                         * @param ResponseInterface $response
876
                         * @return mixed
877
                         */
878
                        public function __invoke(RequestInterface $request, ResponseInterface $response)
879
                        {
880
                            $json = \GuzzleHttp\json_decode($response->getBody()->getContents(), true);
881
                            return array_map(static function (array $v) {
882
                                return $v['s'];
883
                            }, $json);
884
                        }
885
                    },
886
                ]
887
            );
888
        } catch (GuzzleException $e) {
889
            throw new GooglePlayException($e->getMessage(), $e->getCode(), $e);
890
        }
891
    }
892
893
    /**
894
     * Returns a list of applications from the Google Play store for a search query.
895
     *
896
     * @param string $query search query
897
     * @param int $limit limit
898
     * @param PriceEnum|null $price free, paid or both applications
899
     * @param string|null $locale locale or default locale used
900
     * @param string|null $country country or default country used
901
     * @return App[] array of applications with basic information
902
     * @throws GooglePlayException caused by HTTP error
903
     *
904
     * @see PriceEnum::ALL()
905
     * @see PriceEnum::FREE()
906
     * @see PriceEnum::PAID()
907
     */
908
    public function search(
909
        string $query,
910
        int $limit = 50,
911
        ?PriceEnum $price = null,
912
        ?string $locale = null,
913
        ?string $country = null
914
    ): array {
915
        $query = trim($query);
916
        if (empty($query)) {
917
            throw new \InvalidArgumentException('Search query missing');
918
        }
919
        if ($limit < 1) {
920
            throw new \InvalidArgumentException('Invalid count');
921
        }
922
        $locale = LocaleHelper::getNormalizeLocale($locale ?? $this->defaultLocale);
923
        $country = $country ?? $this->defaultCountry;
924
        $price = $price ?? PriceEnum::ALL();
925
        $limit = min($limit, self::MAX_SEARCH_RESULTS);
926
927
        $params = [
928
            'c' => 'apps',
929
            'q' => $query,
930
            'hl' => $locale,
931
            'gl' => $country,
932
            'price' => $price->value(),
933
        ];
934
        $url = self::GOOGLE_PLAY_URL . '/store/search?' . http_build_query($params);
935
936
        $httpClient = $this->getHttpClient();
937
        try {
938
            return $httpClient->request(
939
                'GET',
940
                $url,
941
                [
942
                    HttpClient::OPTION_HANDLER_RESPONSE => new PlayStoreUiPagesScraper($httpClient, $limit, $locale, $country),
943
                ]
944
            );
945
        } catch (GuzzleException $e) {
946
            throw new GooglePlayException($e->getMessage(), $e->getCode(), $e);
947
        }
948
    }
949
950
    /**
951
     * Gets a list of apps from the Google Play store for category and collection.
952
     *
953
     * @param CategoryEnum|null $category application category or null
954
     * @param CollectionEnum $collection коллекция приложения
955
     * @param int $limit application limit
956
     * @param AgeEnum|null $age
957
     * @param string|null $locale locale or default locale used
958
     * @param string|null $country country or default country used
959
     * @return App[] array of applications with basic information
960
     * @throws GooglePlayException caused by HTTP error
961
     *
962
     * @see CategoryEnum
963
     *
964
     * @see CollectionEnum::TOP_FREE()  Top Free
965
     * @see CollectionEnum::TOP_PAID()  Top Paid
966
     * @see CollectionEnum::NEW_FREE()  Top New Free
967
     * @see CollectionEnum::NEW_PAID()  Top New Paid
968
     * @see CollectionEnum::GROSSING()  Top Grossing
969
     * @see CollectionEnum::TRENDING()  Trending Apps
970
     *
971
     * @see AgeEnum::FIVE_UNDER()       Ages 5 and under
972
     * @see AgeEnum::SIX_EIGHT()        Ages 6-8
973
     * @see AgeEnum::NINE_UP()          Ages 9 & Up
974
     */
975
    public function getAppsByCategory(
976
        ?CategoryEnum $category,
977
        CollectionEnum $collection,
978
        int $limit = 60,
979
        ?AgeEnum $age = null,
980
        ?string $locale = null,
981
        ?string $country = null
982
    ): array {
983
        $limit = min(560, max(1, $limit));
984
        $locale = LocaleHelper::getNormalizeLocale($locale ?? $this->defaultLocale);
985
        $country = $country ?? $this->defaultCountry;
986
987
        $url = self::GOOGLE_PLAY_APPS_URL . '';
988
        if ($category !== null) {
989
            $url .= '/category/' . $category->value();
990
        }
991
        $url .= '/collection/' . $collection->value();
992
993
        $offset = 0;
994
995
        $queryParams = [
996
            self::REQ_PARAM_LOCALE => $locale,
997
            self::REQ_PARAM_COUNTRY => $country,
998
        ];
999
        if ($age !== null) {
1000
            $queryParams['age'] = $age->value();
1001
        }
1002
1003
        $results = [];
1004
        $countResults = 0;
1005
        $slice = 0;
1006
        try {
1007
            do {
1008
                if ($offset > 500) {
1009
                    $slice = $offset - 500;
1010
                    $offset = 500;
1011
                }
1012
                $queryParams['num'] = min($limit - $offset + $slice, 60);
1013
1014
                $result = $this->getHttpClient()->request(
1015
                    'POST',
1016
                    $url,
1017
                    [
1018
                        RequestOptions::QUERY => $queryParams,
1019
                        RequestOptions::FORM_PARAMS => [
1020
                            'start' => $offset,
1021
                        ],
1022
                        HttpClient::OPTION_HANDLER_RESPONSE => new ListScraper(),
1023
                    ]
1024
                );
1025
                if ($slice > 0) {
1026
                    $result = array_slice($result, $slice);
1027
                }
1028
                $countResult = count($result);
1029
                $countResults += $countResult;
1030
                $results[] = $result;
1031
                $offset += $countResult;
1032
            } while ($countResult === 60 && $countResults < $limit);
1033
        } catch (GuzzleException $e) {
1034
            throw new GooglePlayException($e->getMessage(), $e->getCode(), $e);
1035
        }
1036
        $results = array_merge(...$results);
1037
        $results = array_slice($results, 0, $limit);
1038
        return $results;
1039
    }
1040
1041
    /**
1042
     * Asynchronously saves images from googleusercontent.com and similar URLs to disk.
1043
     * Before use, you can set the parameters of the width-height of images.
1044
     *
1045
     * @param GoogleImage[] $images
1046
     * @param callable $destPathFn The function to which the GoogleImage object is passed and you must return
1047
     *     the full output. path to save this file. File extension can be omitted.
1048
     *     It will be automatically installed.
1049
     * @param bool $overwrite Overwrite files
1050
     * @return ImageInfo[]
1051
     */
1052
    public function saveGoogleImages(
1053
        array $images,
1054
        callable $destPathFn,
1055
        bool $overwrite = false
1056
    ): array {
1057
        $mapping = [];
1058
        foreach ($images as $image) {
1059
            if (!$image instanceof GoogleImage) {
1060
                throw new \InvalidArgumentException('An array of ' . GoogleImage::class . ' objects is expected.');
1061
            }
1062
            $destPath = $destPathFn($image);
1063
            $mapping[$destPath] = $image->getUrl();
1064
        }
1065
1066
        $httpClient = $this->getHttpClient();
1067
        $promises = (static function () use ($mapping, $overwrite, $httpClient) {
1068
            foreach ($mapping as $destPath => $url) {
1069
                if (!$overwrite && is_file($destPath)) {
1070
                    yield $destPath => new FulfilledPromise($url);
1071
                } else {
1072
                    $dir = dirname($destPath);
1073
                    if (!is_dir($dir) && !mkdir($dir, 0755, true) && !is_dir($dir)) {
1074
                        throw new \RuntimeException(sprintf('Directory "%s" was not created', $dir));
1075
                    }
1076
                    yield $destPath => $httpClient
1077
                        ->requestAsync('GET', $url, [
1078
                            RequestOptions::COOKIES => null,
1079
                            RequestOptions::SINK => $destPath,
1080
                            RequestOptions::HTTP_ERRORS => true,
1081
                        ])
1082
                        ->then(static function (
1083
                            /** @noinspection PhpUnusedParameterInspection */
1084
                            ResponseInterface $response
1085
                        ) use ($url) {
1086
                            return $url;
1087
                        });
1088
                }
1089
            }
1090
        })();
1091
1092
        /**
1093
         * @var ImageInfo[] $imageInfoList
1094
         */
1095
        $imageInfoList = [];
1096
        (new EachPromise($promises, [
1097
            'concurrency' => $this->concurrency,
1098
            'fulfilled' => static function (string $url, string $destPath) use (&$imageInfoList) {
1099
                $imageInfoList[] = new ImageInfo($url, $destPath);
1100
            },
1101
            'rejected' => static function (\Throwable $reason, string $key) use ($mapping) {
1102
                $exceptionUrl = $mapping[$key];
1103
                foreach ($mapping as $destPath => $url) {
1104
                    if (is_file($destPath)) {
1105
                        @unlink($destPath);
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition for unlink(). This can introduce security issues, and is generally not recommended. ( Ignorable by Annotation )

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

1105
                        /** @scrutinizer ignore-unhandled */ @unlink($destPath);

If you suppress an error, we recommend checking for the error condition explicitly:

// For example instead of
@mkdir($dir);

// Better use
if (@mkdir($dir) === false) {
    throw new \RuntimeException('The directory '.$dir.' could not be created.');
}
Loading history...
1106
                    }
1107
                }
1108
                throw (new GooglePlayException($reason->getMessage(), $reason->getCode(), $reason))->setUrl($exceptionUrl);
1109
            },
1110
        ]))->promise()->wait();
1111
1112
        return $imageInfoList;
1113
    }
1114
}
1115