From 39be89f10f5a66be52543759b776f4a35ca1867f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9my=20Munsch?= Date: Fri, 14 Apr 2023 10:30:27 +0200 Subject: [PATCH] - Better refactorisation using Customized api response objects - Replace Raw Guzzle usage with Illuminate Http Client --- app/Jobs/InstaGetTopMediasJob.php | 7 +- .../ApiFetcher.php} | 142 +++++++++--------- app/Lib/Instagram/Objects/TopMedia.php | 48 ++++++ app/Providers/AppServiceProvider.php | 4 +- 4 files changed, 127 insertions(+), 74 deletions(-) rename app/Lib/{InstagramApiFetcher.php => Instagram/ApiFetcher.php} (63%) create mode 100644 app/Lib/Instagram/Objects/TopMedia.php diff --git a/app/Jobs/InstaGetTopMediasJob.php b/app/Jobs/InstaGetTopMediasJob.php index dcb8c5f..1b475b2 100644 --- a/app/Jobs/InstaGetTopMediasJob.php +++ b/app/Jobs/InstaGetTopMediasJob.php @@ -9,6 +9,11 @@ use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Support\Facades\DB; +/** + * Fetch and store the top media list of a specific ig keyword. + * + * @package App\Jobs + */ class InstaGetTopMediasJob implements ShouldQueue { use Dispatchable; @@ -48,7 +53,7 @@ class InstaGetTopMediasJob implements ShouldQueue /** @var \Illuminate\Database\Eloquent\Collection */ $excludeUsers = InstaMediaExclude::query()->where('type', ExcludeTypeEnum::user)->get(); - /** @var \App\Lib\InstagramApiFetcher */ + /** @var \App\Lib\Instagram\ApiFetcher */ $igFetcherClient = \app('Instagram'); $igFetcherClient->fetchHashtagTopMediaList( $hashtag_id, diff --git a/app/Lib/InstagramApiFetcher.php b/app/Lib/Instagram/ApiFetcher.php similarity index 63% rename from app/Lib/InstagramApiFetcher.php rename to app/Lib/Instagram/ApiFetcher.php index 837c3fc..efe709a 100644 --- a/app/Lib/InstagramApiFetcher.php +++ b/app/Lib/Instagram/ApiFetcher.php @@ -1,9 +1,13 @@ $excludedUsers The IG user id we want to fetch. * @param integer $limit The limit of how many post should be fetched. * @param integer $media_count How many media we want to fetch. - * @return \Illuminate\Support\Collection + * @return \Illuminate\Support\Collection + * @throws \RuntimeException If a call error or a response parsing error occurs. */ public function fetchHashtagTopMediaList( int $hashtag_id, @@ -73,6 +78,7 @@ class InstagramApiFetcher 'limit' => $limit ]; + // * Recursive Closure to fetch paginated ig media lists. $fetchHashtagTopMediaList = null; $fetchHashtagTopMediaList = function ( int &$count, @@ -84,8 +90,9 @@ class InstagramApiFetcher $media_count, $fetchHashtagTopMediaList ) { - $json = $this->callApi('GET', $path, $parameters); - $outCollection = \collect($json?->data)->map(function ( + $response = $this->callApi('GET', $path, $parameters); + $json = $response->object(); + $outCollection = \collect($json->data)->map(function ( object $ig_media ) use ( &$count, @@ -112,18 +119,13 @@ class InstagramApiFetcher return null; } $count++; - return (object)[ - 'insta_id' => $ig_media->id, - 'type' => $ig_media->media_type, - 'permalink' => $ig_media->permalink, - 'timestamp' => $ig_media->timestamp, - 'like_count' => \optional($ig_media)->like_count, - 'comments_count' => $ig_media->comments_count, - 'username' => $username, - 'caption' => $ig_media->caption, - ]; + $topMedia = new TopMedia(); + $topMedia->setData($ig_media); + $topMedia->setUserName($username); + return $topMedia; })->filter(); + // * Manual pagination using IG cursors. if ( $count < $media_count && !empty($json->paging->cursors->after) && @@ -139,7 +141,11 @@ class InstagramApiFetcher } return $outCollection; }; - return $fetchHashtagTopMediaList($count, $parameters); + try { + return $fetchHashtagTopMediaList($count, $parameters); + } catch (\ErrorException $e) { + throw new \RuntimeException($e->getMessage(), $e->getCode(), $e); + } } /** @@ -147,28 +153,32 @@ class InstagramApiFetcher * * @param string $url * @return null|string - * @throws \RuntimeException RuntimeException. + * @throws \RuntimeException If a call error or a response parsing error occurs. */ public function fetchUsernameFromPostPermalink(string $url): ?string { - $result = $this->curlCall('GET', $url, parse_json: false); - $html = $result->__toString(); - $dom = new \DOMDocument(); - $dom->loadHTML($html); + try { + $response = $this->httpCall('GET', $url); + $html = $response->body(); + $dom = new \DOMDocument(); + $dom->loadHTML($html); - $scripts = $dom->getElementsByTagName('script'); - return collect($scripts)->filter()->map(function (\DOMElement $script) { - if ($script->getAttribute('type') != 'application/ld+json') { - return null; - } - $entity = json_decode($script->nodeValue, true); - if ($entity['@type'] !== 'SocialMediaPosting') { - return null; - } - return \strval($entity['author']['identifier']['value']); - })->filter(function ($el) { - return !empty($el); - })->first(); + $scripts = $dom->getElementsByTagName('script'); + return collect($scripts)->filter()->map(function (\DOMElement $script) { + if ($script->getAttribute('type') != 'application/ld+json') { + return null; + } + $entity = json_decode($script->nodeValue, true); + if ($entity['@type'] !== 'SocialMediaPosting') { + return null; + } + return \strval($entity['author']['identifier']['value']); + })->filter(function ($el) { + return !empty($el); + })->first(); + } catch (\Exception $e) { + throw new \RuntimeException($e->getMessage(), $e->getCode(), $e); + } //end try } /** @@ -177,15 +187,15 @@ class InstagramApiFetcher * @param string $method The HTTP VERB to use. * @param string $path The path api to call. * @param array $params Api call parameters to pass on. - * @return object + * @return \Illuminate\Http\Client\Response * @throws \RuntimeException Error on call. */ private function callApi( string $method, string $path, array $params = [] - ): object { - return $this->curlCall( + ): Response { + return $this->httpCall( $method, \sprintf( "%s/%s/%s", @@ -193,53 +203,43 @@ class InstagramApiFetcher $this->version, $path ), - [ - 'query' => array_merge( - [ - 'access_token' => $this->access_token - ], - $params - ) - ] + array_merge( + [ + 'access_token' => $this->access_token + ], + $params + ) ); } /** * Call url and fetch result in array. * - * @param string $method The HTTP VERB to use. + * @param string $method The HTTP VERB to use (GET, POST). * @param string|\Psr\Http\Message\UriInterface $uri The uri to call. - * @param array $options The options to pass on. - * @param boolean $parse_json Wether to return data as parsed Json array or raw ouput. - * @return object|\Psr\Http\Message\StreamInterface + * @param array $parameters The call parameters to pass on. + * @return \Illuminate\Http\Client\Response + * @throws \Error If the request method is not supported. * @throws \RuntimeException If something goes wrong. */ - private function curlCall( + private function httpCall( string $method, string|UriInterface $uri = '', - array $options = [], - bool $parse_json = true - ): object { + array $parameters = [] + ): Response { try { - $client = new \GuzzleHttp\Client(); - $res = $client->request($method, $uri, $options); - $status = $res->getStatusCode(); - $body = $res->getBody(); - $e = new \RuntimeException("Failed to decode IG response \n {$body}"); + if (!\in_array(\strtoupper($method), ['GET', 'POST'])) { + throw new \Error("Unsupported method {$method}"); + } + /** @var \Illuminate\Http\Client\Response */ + $response = Http::connectTimeout(\config('app.debug') ? 3 : 30) + ->retry(2, 100) + ->{strtolower($method)}($uri, $parameters); + // * Throw an exception if an error occured. + $response->throw(); - if ($status !== 200) { - throw $e; - } - if (!$parse_json) { - return $body; - } - /** @var array|boolean|null */ - $out = \json_decode($body); - if (!\is_object($out)) { - throw $e; - } - return $out; - } catch (\GuzzleHttp\Exception\ClientException $e) { + return $response; + } catch (HttpClientException $e) { throw new \RuntimeException($e); } //end try } diff --git a/app/Lib/Instagram/Objects/TopMedia.php b/app/Lib/Instagram/Objects/TopMedia.php new file mode 100644 index 0000000..59b1cee --- /dev/null +++ b/app/Lib/Instagram/Objects/TopMedia.php @@ -0,0 +1,48 @@ +insta_id = $ig_media->id; + $this->type = $ig_media->media_type; + $this->permalink = $ig_media->permalink; + $this->timestamp = $ig_media->timestamp; + $this->comments_count = \intval($ig_media->comments_count); + $this->like_count = \optional($ig_media)->like_count ? \intval($ig_media->like_count) : null; + $this->caption = $ig_media->caption; + } + + /** + * Set this object username + * + * @param string $username + * @return void + */ + public function setUserName(string $username): void + { + $this->username = $username; + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 9545729..860d1c5 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,7 +2,7 @@ namespace App\Providers; -use App\Lib\InstagramApiFetcher; +use App\Lib\Instagram\ApiFetcher; use Illuminate\Pagination\Paginator; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\App; @@ -72,7 +72,7 @@ class AppServiceProvider extends ServiceProvider ); } - return new InstagramApiFetcher( + return new ApiFetcher( $url, $version, $access_token