109 lines
3.4 KiB
PHP
109 lines
3.4 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use Illuminate\Support\Facades\Http;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Illuminate\Support\Facades\Storage;
|
|
use Throwable;
|
|
|
|
/**
|
|
* Shared logic for downloading a model's Instagram-hosted media into local
|
|
* storage and rewriting its url columns to point at the cached copy.
|
|
* Instagram's CDN urls are signed and expire, so caching is a one-way trip:
|
|
* once a field holds a local url it's left alone on every later call, which
|
|
* makes cache() safe to call repeatedly. It also repairs any url that was
|
|
* cached as an absolute address before switching to relative ones, without
|
|
* re-downloading — the file is already on disk.
|
|
*/
|
|
abstract class MediaCacher
|
|
{
|
|
private const INSTAGRAM_HOST_SUFFIXES = ['fbcdn.net', 'cdninstagram.com'];
|
|
|
|
/**
|
|
* True if the url still points at Instagram's own CDN (i.e. hasn't been
|
|
* cached locally yet). Public so callers writing scraped data back to
|
|
* an existing row can check it before deciding whether to overwrite a
|
|
* field a cache pass already replaced.
|
|
*/
|
|
public function isInstagramUrl(?string $url): bool
|
|
{
|
|
if (! $url) {
|
|
return false;
|
|
}
|
|
|
|
$host = parse_url($url, PHP_URL_HOST);
|
|
|
|
if (! $host) {
|
|
return false;
|
|
}
|
|
|
|
foreach (self::INSTAGRAM_HOST_SUFFIXES as $suffix) {
|
|
if (str_ends_with($host, $suffix)) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Figures out what, if anything, a url field needs to become: null if
|
|
* it's already a relative path we generated (nothing to do), a
|
|
* rewritten relative path if it's an absolute url pointing at our own
|
|
* /storage/ (cached before the relative-path fix, no download needed),
|
|
* a freshly downloaded local path if it's still on Instagram's CDN, or
|
|
* null for anything else we don't recognize.
|
|
*/
|
|
protected function resolveUrl(?string $url, string $pathWithoutExtension): ?string
|
|
{
|
|
if (! $url || str_starts_with($url, '/storage/')) {
|
|
return null;
|
|
}
|
|
|
|
if (($pos = strpos($url, '/storage/')) !== false) {
|
|
return substr($url, $pos);
|
|
}
|
|
|
|
if ($this->isInstagramUrl($url)) {
|
|
return $this->download($url, $pathWithoutExtension);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private function download(string $url, string $pathWithoutExtension): ?string
|
|
{
|
|
try {
|
|
$response = Http::withHeaders(['User-Agent' => 'Mozilla/5.0'])->timeout(30)->get($url);
|
|
} catch (Throwable $e) {
|
|
Log::warning(static::class.": failed to fetch {$url}: {$e->getMessage()}");
|
|
|
|
return null;
|
|
}
|
|
|
|
if (! $response->successful()) {
|
|
Log::warning(static::class.": got {$response->status()} fetching {$url}");
|
|
|
|
return null;
|
|
}
|
|
|
|
$path = $pathWithoutExtension.'.'.$this->guessExtension($url, $response->header('Content-Type'));
|
|
|
|
Storage::disk('public')->put($path, $response->body());
|
|
|
|
return '/storage/'.$path;
|
|
}
|
|
|
|
private function guessExtension(string $url, ?string $contentType): string
|
|
{
|
|
$fromUrl = pathinfo(parse_url($url, PHP_URL_PATH) ?? '', PATHINFO_EXTENSION);
|
|
|
|
if ($fromUrl && strlen($fromUrl) <= 4) {
|
|
return strtolower($fromUrl);
|
|
}
|
|
|
|
return str_contains((string) $contentType, 'video') ? 'mp4' : 'jpg';
|
|
}
|
|
}
|