1
|
|
|
<?php |
2
|
|
|
/** |
3
|
|
|
* xMDB-API |
4
|
|
|
* |
5
|
|
|
* Copyright © 2017 pudelek.org.pl |
6
|
|
|
* |
7
|
|
|
* @license MIT License (MIT) |
8
|
|
|
* |
9
|
|
|
* For the full copyright and license information, please view source file |
10
|
|
|
* that is bundled with this package in the file LICENSE |
11
|
|
|
* |
12
|
|
|
* @author Marcin Pudełek <[email protected]> |
13
|
|
|
*/ |
14
|
|
|
|
15
|
|
|
/** |
16
|
|
|
* Created by Marcin. |
17
|
|
|
* Date: 01.12.2017 |
18
|
|
|
* Time: 22:52 |
19
|
|
|
*/ |
20
|
|
|
|
21
|
|
|
namespace mrcnpdlk\Xmdb; |
22
|
|
|
|
23
|
|
|
|
24
|
|
|
use Campo\UserAgent; |
25
|
|
|
use Curl\Curl; |
26
|
|
|
use HttpLib\Http; |
27
|
|
|
use Imdb\Config; |
28
|
|
|
use Imdb\Title as ApiTitle; |
29
|
|
|
use Imdb\TitleSearch; |
30
|
|
|
use Imdb\TitleSearch as ApiTitleSearch; |
31
|
|
|
use KHerGe\JSON\JSON; |
32
|
|
|
use mrcnpdlk\Xmdb\Model\Imdb\Character; |
33
|
|
|
use mrcnpdlk\Xmdb\Model\Imdb\Image; |
34
|
|
|
use mrcnpdlk\Xmdb\Model\Imdb\Info; |
35
|
|
|
use mrcnpdlk\Xmdb\Model\Imdb\Person; |
36
|
|
|
use mrcnpdlk\Xmdb\Model\Imdb\Rating; |
37
|
|
|
use mrcnpdlk\Xmdb\Model\Imdb\Ratio; |
38
|
|
|
use mrcnpdlk\Xmdb\Model\Imdb\Title; |
39
|
|
|
use Sunra\PhpSimple\HtmlDomParser; |
40
|
|
|
|
41
|
|
|
class Imdb |
42
|
|
|
{ |
43
|
|
|
/** |
44
|
|
|
* @var \mrcnpdlk\Xmdb\Client |
45
|
|
|
*/ |
46
|
|
|
private $oClient; |
47
|
|
|
/** |
48
|
|
|
* @var \Imdb\Config |
49
|
|
|
*/ |
50
|
|
|
private $oConfig; |
51
|
|
|
/** @noinspection PhpUndefinedClassInspection */ |
52
|
|
|
|
53
|
|
|
/** |
54
|
|
|
* @var \Psr\Log\LoggerInterface |
55
|
|
|
*/ |
56
|
|
|
private $oLog; |
57
|
|
|
/** |
58
|
|
|
* @var \Psr\SimpleCache\CacheInterface |
59
|
|
|
*/ |
60
|
|
|
private $oCache; |
61
|
|
|
|
62
|
|
|
/** |
63
|
|
|
* Imdb constructor. |
64
|
|
|
* |
65
|
|
|
* @param \mrcnpdlk\Xmdb\Client $oClient |
66
|
|
|
* |
67
|
|
|
* @throws \mrcnpdlk\Xmdb\Exception |
68
|
|
|
*/ |
69
|
|
|
public function __construct(Client $oClient) |
70
|
|
|
{ |
71
|
|
|
try { |
72
|
|
|
$this->oClient = $oClient; |
73
|
|
|
$this->oLog = $oClient->getLogger(); |
74
|
|
|
$this->oCache = $oClient->getAdapter()->getCache(); |
75
|
|
|
$this->oConfig = new Config(); |
76
|
|
|
$this->oConfig->usecache = null !== $this->oCache; |
77
|
|
|
$this->oConfig->language = $oClient->getLang(); |
78
|
|
|
$this->oConfig->default_agent = UserAgent::random(); |
79
|
|
|
|
80
|
|
|
|
81
|
|
|
} catch (\Exception $e) { |
82
|
|
|
throw new Exception(sprintf('Cannot create Tmdb Client'), 1, $e); |
83
|
|
|
} |
84
|
|
|
} |
85
|
|
|
|
86
|
|
|
/** |
87
|
|
|
* @param string $imdbId |
88
|
|
|
* |
89
|
|
|
* @return \mrcnpdlk\Xmdb\Model\Imdb\Info |
90
|
|
|
* @throws \mrcnpdlk\Xmdb\Exception |
91
|
|
|
*/ |
92
|
|
|
public function getInfo(string $imdbId): Info |
93
|
|
|
{ |
94
|
|
|
try { |
95
|
|
|
$searchUrl = 'http://app.imdb.com/title/maindetails?tconst=' . $imdbId; |
96
|
|
|
|
97
|
|
|
$oResp = $this->oClient->getAdapter()->useCache( |
98
|
|
|
function () use ($searchUrl) { |
99
|
|
|
$oCurl = new Curl(); |
100
|
|
|
$oCurl->setUserAgent(UserAgent::random()); |
101
|
|
|
$oCurl->setHeader('Accept-Language', $this->oClient->getLang()); |
102
|
|
|
$oCurl->get($searchUrl); |
103
|
|
|
|
104
|
|
|
if ($oCurl->error) { |
105
|
|
|
throw new \RuntimeException('Curl Error! ' . Http::message($oCurl->httpStatusCode), $oCurl->error_code); |
106
|
|
|
} |
107
|
|
|
|
108
|
|
|
return $oCurl->response->data ?? null; |
109
|
|
|
}, |
110
|
|
|
[$searchUrl, $this->oClient->getLang()], |
111
|
|
|
3600 * 2) |
112
|
|
|
; |
113
|
|
|
$oData = $oResp->data ?? $oResp; |
114
|
|
|
|
115
|
|
|
$oApiTitle = $this->getApiTitle($imdbId); |
116
|
|
|
|
117
|
|
|
$oInfo = new Info(); |
118
|
|
|
$oInfo->id = $oData->tconst; |
119
|
|
|
$oInfo->title = $oData->title; |
120
|
|
|
$oInfo->year = $oData->year; |
121
|
|
|
$oInfo->image = isset($oData->image) ? new Image($oData->image->url, $oData->image->width, $oData->image->height) : null; |
122
|
|
|
$oInfo->releaseDate = $oData->release_date->normal ?? null; |
123
|
|
|
$oInfo->genres = $oData->genres; |
124
|
|
|
$oInfo->rating = $oData->rating; |
125
|
|
|
$oInfo->votes = $oData->num_votes; |
126
|
|
|
|
127
|
|
|
$sRuntime = $oApiTitle->runtime(); |
128
|
|
|
$oInfo->runtime = null === $sRuntime ? null : (int)$sRuntime; |
129
|
|
|
|
130
|
|
|
$tmp = []; |
131
|
|
View Code Duplication |
foreach ($oData->directors_summary ?? [] as $oDir) { |
|
|
|
|
132
|
|
|
$oPerson = $oDir->name; |
133
|
|
|
$oImage = isset($oPerson->image) ? new Image($oPerson->image->url, $oPerson->image->width, |
134
|
|
|
$oPerson->image->height) : null; |
135
|
|
|
$oInfo->directors[] = new Person($oPerson->nconst, $oPerson->name, $oImage); |
136
|
|
|
$tmp[] = $oPerson->name; |
137
|
|
|
} |
138
|
|
|
$oInfo->directorsDisplay = implode(', ', $tmp); |
139
|
|
|
|
140
|
|
|
$tmp = []; |
141
|
|
View Code Duplication |
foreach ($oData->writers_summary ?? [] as $oDir) { |
|
|
|
|
142
|
|
|
$oPerson = $oDir->name; |
143
|
|
|
$oImage = isset($oPerson->image) ? new Image($oPerson->image->url, $oPerson->image->width, |
144
|
|
|
$oPerson->image->height) : null; |
145
|
|
|
$oInfo->writers[] = new Person($oPerson->nconst, $oPerson->name, $oImage); |
146
|
|
|
$tmp[] = $oPerson->name; |
147
|
|
|
} |
148
|
|
|
$oInfo->writersDisplay = implode(', ', $tmp); |
149
|
|
|
|
150
|
|
|
foreach ($oData->photos ?? [] as $photo) { |
151
|
|
|
$oInfo->photos[] = new Image($photo->image->url, $photo->image->width, $photo->image->height); |
152
|
|
|
} |
153
|
|
|
|
154
|
|
|
foreach ($oData->cast_summary ?? [] as $ch) { |
155
|
|
|
$oImage = isset($ch->name->image) ? new Image($ch->name->image->url, $ch->name->image->width, |
156
|
|
|
$ch->name->image->height) : null; |
157
|
|
|
$oPerson = $ch->name ? new Person($ch->name->nconst, $ch->name->name, $oImage) : null; |
158
|
|
|
$oInfo->cast[] = new Character($ch->char, $oPerson); |
159
|
|
|
} |
160
|
|
|
|
161
|
|
|
|
162
|
|
|
$oInfo->countries = $oApiTitle->country(); |
163
|
|
|
|
164
|
|
|
|
165
|
|
|
$oInfo->genresDisplay = implode(', ', $oInfo->genres); |
166
|
|
|
$oInfo->countriesDisplay = implode(', ', $oInfo->countries); |
167
|
|
|
|
168
|
|
|
return $oInfo; |
169
|
|
|
|
170
|
|
|
} catch (\Exception $e) { |
171
|
|
|
throw new Exception(sprintf('Item [%s] not found, reason: %s', $imdbId, $e->getMessage())); |
172
|
|
|
} |
173
|
|
|
} |
174
|
|
|
|
175
|
|
|
/** |
176
|
|
|
* @param string $imdbId |
177
|
|
|
* |
178
|
|
|
* @return \Imdb\Title |
179
|
|
|
*/ |
180
|
|
|
protected function getApiTitle(string $imdbId): ApiTitle |
181
|
|
|
{ |
182
|
|
|
return new ApiTitle($imdbId, $this->oConfig, $this->oLog, $this->oCache); |
183
|
|
|
} |
184
|
|
|
|
185
|
|
|
/** |
186
|
|
|
* @param string $imdbId |
187
|
|
|
* |
188
|
|
|
* @return \mrcnpdlk\Xmdb\Model\Imdb\Rating |
189
|
|
|
* @throws \mrcnpdlk\Xmdb\Exception |
190
|
|
|
*/ |
191
|
|
|
public function getRating(string $imdbId): Rating |
192
|
|
|
{ |
193
|
|
|
try { |
194
|
|
|
$searchUrl = 'http://p.media-imdb.com/static-content/documents/v1/title/' |
195
|
|
|
. $imdbId |
196
|
|
|
. '/ratings%3Fjsonp=imdb.rating.run:imdb.api.title.ratings/data.json?u=' |
197
|
|
|
. $this->oClient->getImdbUser(); |
198
|
|
|
|
199
|
|
|
$oResp = $this->oClient->getAdapter()->useCache( |
200
|
|
|
function () use ($searchUrl) { |
201
|
|
|
$oCurl = new Curl(); |
202
|
|
|
$oCurl->setOpt(\CURLOPT_ENCODING, 'gzip'); |
203
|
|
|
$oCurl->setUserAgent(UserAgent::random()); |
204
|
|
|
$oCurl->setHeader('Accept-Language', $this->oClient->getLang()); |
205
|
|
|
$oCurl->get($searchUrl); |
206
|
|
|
|
207
|
|
|
if ($oCurl->error) { |
208
|
|
|
throw new \RuntimeException('Curl Error! ' . Http::message($oCurl->httpStatusCode), $oCurl->error_code); |
209
|
|
|
} |
210
|
|
|
|
211
|
|
|
preg_match("/^[\w\.]*\((.*)\)$/", $oCurl->response, $output_array); |
212
|
|
|
$json = new JSON(); |
213
|
|
|
|
214
|
|
|
return $json->decode($output_array[1]); |
215
|
|
|
}, |
216
|
|
|
[$searchUrl, $this->oClient->getLang()], |
217
|
|
|
180) |
218
|
|
|
; |
219
|
|
|
|
220
|
|
|
if (!isset($oResp->resource)) { |
221
|
|
|
throw new \RuntimeException('Resource is empty'); |
222
|
|
|
} |
223
|
|
|
|
224
|
|
|
$oData = $oResp->resource; |
225
|
|
|
|
226
|
|
|
$oRating = new Rating(); |
227
|
|
|
$oRating->id = $imdbId; |
228
|
|
|
$oRating->title = $oData->title; |
229
|
|
|
$oRating->year = $oData->year; |
230
|
|
|
$oRating->rating = $oData->rating; |
231
|
|
|
$oRating->votes = $oData->ratingCount; |
232
|
|
|
$oRating->type = $oData->titleType; |
233
|
|
|
|
234
|
|
|
return $oRating; |
235
|
|
|
|
236
|
|
|
} catch (\Exception $e) { |
237
|
|
|
throw new Exception(sprintf('Item [%s] not found, reason: %s', $imdbId, $e->getMessage())); |
238
|
|
|
} |
239
|
|
|
} |
240
|
|
|
|
241
|
|
|
/** |
242
|
|
|
* Combined search by title |
243
|
|
|
* |
244
|
|
|
* @param string $title |
245
|
|
|
* @param int|null $limit |
246
|
|
|
* @param \mrcnpdlk\Xmdb\Model\Imdb\Ratio|null $oRatio |
247
|
|
|
* |
248
|
|
|
* @return Title[] |
249
|
|
|
*/ |
250
|
|
|
public function searchByTitle(string $title, int $limit = null, Ratio $oRatio = null): array |
251
|
|
|
{ |
252
|
|
|
/** |
253
|
|
|
* @var Title[] $answer |
254
|
|
|
* @var Title[] $tmpList |
255
|
|
|
*/ |
256
|
|
|
$answer = []; |
257
|
|
|
$tmpList = []; |
258
|
|
|
$tNativeList = $this->searchByTitleNative($title); |
259
|
|
|
foreach ($tNativeList as $item) { |
260
|
|
|
$tmpList[$item->imdbId] = $item; |
261
|
|
|
} |
262
|
|
|
|
263
|
|
|
$tApiList = $this->searchByTitleApi($title); |
264
|
|
|
foreach ($tApiList as $item) { |
265
|
|
|
if (!\array_key_exists($item->imdbId, $tmpList)) { |
266
|
|
|
$tmpList[$item->imdbId] = $item; |
267
|
|
|
} else { |
268
|
|
|
$tmpList[$item->imdbId]->isMovie = $tmpList[$item->imdbId]->isMovie ?? $item->isMovie; |
269
|
|
|
$tmpList[$item->imdbId]->type = $tmpList[$item->imdbId]->type ?? $item->type; |
270
|
|
|
} |
271
|
|
|
} |
272
|
|
|
|
273
|
|
|
foreach ($tmpList as $item) { |
274
|
|
|
$answer[] = $item; |
275
|
|
|
} |
276
|
|
|
|
277
|
|
|
if ($oRatio) { |
278
|
|
|
$oRatio->calculateRatio($answer); |
279
|
|
|
$answer = []; |
280
|
|
|
foreach ($oRatio->items as $oRatioElement) { |
281
|
|
|
$answer[] = $oRatioElement->item; |
282
|
|
|
} |
283
|
|
|
|
284
|
|
|
} |
285
|
|
|
|
286
|
|
|
return $limit === null ? $answer : \array_slice($answer, 0, $limit); |
287
|
|
|
} |
288
|
|
|
|
289
|
|
|
/** |
290
|
|
|
* @param string $title |
291
|
|
|
* |
292
|
|
|
* @return Title[] |
293
|
|
|
*/ |
294
|
|
|
public function searchByTitleNative(string $title): array |
295
|
|
|
{ |
296
|
|
|
try { |
297
|
|
|
/** |
298
|
|
|
* @var Title[] $answer |
299
|
|
|
*/ |
300
|
|
|
$answer = []; |
301
|
|
|
$params = [ |
302
|
|
|
'sort' => 'moviemeter,asc', |
303
|
|
|
'view' => 'advanced', |
304
|
|
|
'title' => $title, |
305
|
|
|
]; |
306
|
|
|
$searchUrl = 'http://www.imdb.com/search/title?' . http_build_query($params); |
307
|
|
|
|
308
|
|
|
$htmlContent = $this->oClient->getAdapter()->useCache( |
309
|
|
|
function () use ($searchUrl) { |
310
|
|
|
$oCurl = new Curl(); |
311
|
|
|
$oCurl->setHeader('Accept-Language', $this->oClient->getLang()); |
312
|
|
|
|
313
|
|
|
return $oCurl->get($searchUrl); |
314
|
|
|
}, |
315
|
|
|
[$searchUrl, $this->oClient->getLang()], |
316
|
|
|
3600 * 2) |
317
|
|
|
; |
318
|
|
|
|
319
|
|
|
$html = HtmlDomParser::str_get_html($htmlContent); |
320
|
|
|
if (!$html) { |
321
|
|
|
throw new \RuntimeException('Response from IMDB malformed!'); |
322
|
|
|
} |
323
|
|
|
$listerItemNode = $html->find('div.lister-item'); |
324
|
|
|
|
325
|
|
|
foreach ($listerItemNode as $element) { |
326
|
|
|
$content = $element->find('div.lister-item-content', 0); |
327
|
|
|
if (!$content) { |
328
|
|
|
throw new \RuntimeException('Empty search result!'); |
329
|
|
|
} |
330
|
|
|
$titleNode = $content->find('h3.lister-item-header a', 0); |
331
|
|
|
if ($titleNode) { |
332
|
|
|
$foundTitle = trim($titleNode->text()); |
333
|
|
|
} else { |
334
|
|
|
throw new \RuntimeException('Title not found!'); |
335
|
|
|
} |
336
|
|
|
$yearNode = $content->find('h3.lister-item-header .lister-item-year', 0); |
337
|
|
|
if ($yearNode) { |
338
|
|
|
$foundYear = trim($yearNode->text()); |
339
|
|
|
preg_match("/^\(?([0-9\-\–\s]+)([\w\s]*)?\)?$/u", $foundYear, $yearOut); |
340
|
|
|
$foundYear = isset($yearOut[1]) ? trim($yearOut[1]) : $foundYear; |
341
|
|
|
$foundType = isset($yearOut[2]) && trim($yearOut[2]) ? trim($yearOut[2]) : null; |
342
|
|
|
} else { |
343
|
|
|
$foundYear = null; |
344
|
|
|
$foundType = null; |
345
|
|
|
} |
346
|
|
|
$episodeNode = $content->find('h3.lister-item-header a', 1); |
347
|
|
|
$foundEpisode = null; |
348
|
|
|
if ($episodeNode) { |
349
|
|
|
$foundEpisode = trim($episodeNode->text()); |
350
|
|
|
} |
351
|
|
|
$ratingNode = $content->find('div.ratings-bar div.ratings-imdb-rating', 0); |
352
|
|
|
$foundRating = null; |
353
|
|
|
if ($ratingNode) { |
354
|
|
|
$foundRating = trim($ratingNode->text()); |
355
|
|
|
} |
356
|
|
|
|
357
|
|
|
$metascoreNode = $content->find('div.ratings-bar div.ratings-metascore', 0); |
358
|
|
|
$foundMetascore = null; |
359
|
|
|
if ($metascoreNode) { |
360
|
|
|
$oSpanNode = $metascoreNode->find('span', 0); |
361
|
|
|
$foundMetascore = $oSpanNode ? (int)$oSpanNode->text() : null; |
362
|
|
|
} |
363
|
|
|
|
364
|
|
|
$idNode = $element->find('div.lister-top-right div.ribbonize', 0); |
365
|
|
|
$imdbId = null; |
366
|
|
|
if ($idNode) { |
367
|
|
|
$imdbId = $idNode->getAttribute('data-tconst'); |
368
|
|
|
} |
369
|
|
|
|
370
|
|
|
$directors = []; |
371
|
|
|
$stars = []; |
372
|
|
|
$personsNode = $content->find('p', 2); |
373
|
|
|
if ($personsNode) { |
374
|
|
|
$persons = $personsNode->find('a'); |
375
|
|
|
if (\is_array($persons)) { |
376
|
|
|
foreach ($persons as $person) { |
377
|
|
|
if (strpos($person->getAttribute('href'), 'adv_li_dr_') !== false) { |
378
|
|
|
$directors[] = $person->text(); |
379
|
|
|
} |
380
|
|
|
if (strpos($person->getAttribute('href'), 'adv_li_st_') !== false) { |
381
|
|
|
$stars[] = $person->text(); |
382
|
|
|
} |
383
|
|
|
} |
384
|
|
|
} |
385
|
|
|
} |
386
|
|
|
|
387
|
|
|
if ($imdbId) { |
388
|
|
|
$oTitle = new Title(); |
389
|
|
|
$oTitle->title = $foundTitle; |
390
|
|
|
$oTitle->titleOrg = $foundTitle; |
391
|
|
|
$oTitle->imdbId = $imdbId; |
392
|
|
|
$oTitle->rating = $foundRating; |
|
|
|
|
393
|
|
|
$oTitle->metascore = $foundMetascore; |
394
|
|
|
$oTitle->episode = $foundEpisode; |
395
|
|
|
$oTitle->year = $foundYear; |
396
|
|
|
$oTitle->type = $foundType; |
397
|
|
|
$oTitle->director = $directors; |
398
|
|
|
$oTitle->directorDisplay = implode(', ', $oTitle->director); |
399
|
|
|
$oTitle->star = $stars; |
400
|
|
|
$oTitle->starDisplay = implode(', ', $oTitle->star); |
401
|
|
|
|
402
|
|
|
$answer[] = $oTitle; |
403
|
|
|
} |
404
|
|
|
|
405
|
|
|
} |
406
|
|
|
|
407
|
|
|
return $answer; |
408
|
|
|
} catch (\Exception $e) { |
409
|
|
|
$this->oLog->warning(sprintf('Item [%s] not found, reason: %s', $title, $e->getMessage())); |
410
|
|
|
|
411
|
|
|
return []; |
412
|
|
|
} |
413
|
|
|
} |
414
|
|
|
|
415
|
|
|
/** |
416
|
|
|
* @param string $title |
417
|
|
|
* |
418
|
|
|
* @return Title[] |
419
|
|
|
*/ |
420
|
|
|
public function searchByTitleApi(string $title): array |
421
|
|
|
{ |
422
|
|
|
try { |
423
|
|
|
$answer = []; |
424
|
|
|
$tList = $this->getApiTitleSearch()->search($title, [ |
425
|
|
|
TitleSearch::MOVIE, |
426
|
|
|
TitleSearch::TV_SERIES, |
427
|
|
|
TitleSearch::VIDEO, |
428
|
|
|
TitleSearch::TV_MOVIE, |
429
|
|
|
]) |
430
|
|
|
; |
431
|
|
|
|
432
|
|
|
foreach ($tList as $element) { |
433
|
|
|
$oTitle = new Title(); |
434
|
|
|
$oTitle->imdbId = 'tt' . $element->imdbid(); |
435
|
|
|
$oTitle->title = $element->title(); |
436
|
|
|
$oTitle->titleOrg = $element->title(); |
437
|
|
|
$oTitle->rating = null; //set null for speedy |
438
|
|
|
$oTitle->episode = null; |
439
|
|
|
$oTitle->year = empty($element->year()) ? null : $element->year(); |
440
|
|
|
$oTitle->type = $element->movietype(); |
441
|
|
|
$oTitle->isMovie = \in_array($element->movietype(), [TitleSearch::MOVIE, TitleSearch::TV_MOVIE, TitleSearch::VIDEO], |
442
|
|
|
true); |
443
|
|
|
$oTitle->director = []; |
444
|
|
|
$oTitle->directorDisplay = implode(', ', $oTitle->director); |
445
|
|
|
$oTitle->star = []; |
446
|
|
|
$oTitle->starDisplay = implode(', ', $oTitle->star); |
447
|
|
|
$answer[] = $oTitle; |
448
|
|
|
} |
449
|
|
|
|
450
|
|
|
return $answer; |
451
|
|
|
} catch (\Exception $e) { |
452
|
|
|
$this->oLog->warning(sprintf('Item [%s] not found, reason: %s', $title, $e->getMessage())); |
453
|
|
|
|
454
|
|
|
return []; |
455
|
|
|
} |
456
|
|
|
} |
457
|
|
|
|
458
|
|
|
/** |
459
|
|
|
* @return \Imdb\TitleSearch |
460
|
|
|
*/ |
461
|
|
|
protected function getApiTitleSearch(): TitleSearch |
462
|
|
|
{ |
463
|
|
|
return new ApiTitleSearch($this->oConfig, $this->oLog, $this->oCache); |
464
|
|
|
} |
465
|
|
|
} |
466
|
|
|
|
Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.
You can also find more detailed suggestions in the “Code” section of your repository.