Реалізація автоматичного завантаження та завивання зображень товарів
При масовому імпорті товарів зображення — найбільш обсяжна та трудомістка частина. Постачальник надсилає посилання або шляхи в прайсі, а магазин повинен завантажити, оптимізувати та зберегти файли у власному сховищі. Робити це вручну при каталозі від 1 000 позицій нереально.
Звідки беруться посилання на зображення
-
У CSV/Excel — колонка з URL або відносним шляхом:
https://supplier.ua/images/ABC-123_1.jpg -
У XML/YML — теги
<picture>або<image> -
У API-відповіді — масив
images: [{url, sort, is_main}] - На FTP постачальника — файли в директорії, назва файлу = артикул
Архітектура конвеєру завантаження
Import Job
└─> parse product data
└─> enqueue ImageDownloadJob(sku, urls[])
└─> download each URL (HTTP)
└─> validate (mime, size)
└─> optimize (resize, convert to WebP)
└─> upload to storage (S3 / local)
└─> save to product_images table
Завантаження зображень винесено в окремий Job, щоб не блокувати основний імпорт.
Завантаження з повторними спробами
class ImageDownloadJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue;
public int $tries = 3;
public int $backoff = 30;
public int $timeout = 60;
public function __construct(
public readonly int $productId,
public readonly array $urls,
) {}
public function handle(ImageProcessor $processor): void
{
foreach ($this->urls as $index => $url) {
try {
$tmpPath = $processor->download($url);
$stored = $processor->processAndStore($tmpPath, $this->productId, $index);
ProductImage::updateOrCreate(
['product_id' => $this->productId, 'sort' => $index],
['path' => $stored, 'is_main' => $index === 0, 'source_url' => $url]
);
} catch (\Exception $e) {
Log::warning("Image download failed: {$url} — {$e->getMessage()}");
}
}
}
}
Валідація завантаженого файлу
class ImageProcessor
{
private const ALLOWED_MIME = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'];
private const MAX_SIZE = 20 * 1024 * 1024; // 20 МБ
public function download(string $url): string
{
$response = $this->client->get($url, ['timeout' => 30, 'stream' => true]);
$tmpPath = tempnam(sys_get_temp_dir(), 'img_');
$body = $response->getBody();
$size = 0;
$fp = fopen($tmpPath, 'wb');
while (!$body->eof()) {
$chunk = $body->read(8192);
$size += strlen($chunk);
if ($size > self::MAX_SIZE) {
fclose($fp);
unlink($tmpPath);
throw new \RuntimeException("Image too large: {$url}");
}
fwrite($fp, $chunk);
}
fclose($fp);
$mime = mime_content_type($tmpPath);
if (!in_array($mime, self::ALLOWED_MIME)) {
unlink($tmpPath);
throw new \RuntimeException("Invalid MIME type: {$mime} for {$url}");
}
return $tmpPath;
}
}
Оптимізація та конвертація
Використовуємо intervention/image (v3) для ресайзу та Spatie image-optimizer для стиснення:
public function processAndStore(string $tmpPath, int $productId, int $sort): string
{
$manager = new \Intervention\Image\ImageManager(
new \Intervention\Image\Drivers\Gd\Driver()
);
$image = $manager->read($tmpPath);
// Генеруємо кілька розмірів
$variants = [
'full' => [1200, 1200],
'catalog' => [400, 400],
'thumbnail' => [100, 100],
];
$paths = [];
foreach ($variants as $name => [$w, $h]) {
$resized = clone $image;
$resized->coverDown($w, $h);
$filename = "products/{$productId}/{$sort}_{$name}.webp";
$encoded = $resized->toWebp(quality: 85);
Storage::disk('public')->put($filename, $encoded);
$paths[$name] = $filename;
}
unlink($tmpPath);
return json_encode($paths);
}
coverDown обрізає зображення по центру, зберігаючи пропорції — стандарт для каталожних фото.
Дедублікація: не завантажувати повторно
Якщо постачальник прислав той же URL — не тратити трафік і час:
$existing = ProductImage::where([
'product_id' => $productId,
'source_url' => $url,
])->first();
if ($existing && Storage::exists($existing->path)) {
continue; // вже є, пропустити
}
Для більш надійної дедублікації — зберігати хеш контенту (SHA-256 перших 4 КБ): один і той же файл з різних URL не завантажиться двічі.
Завантаження з FTP постачальника
class FtpImageSource
{
public function syncForProduct(string $sku): array
{
$ftp = ftp_connect($this->host);
ftp_login($ftp, $this->user, $this->pass);
$files = ftp_nlist($ftp, $this->baseDir);
$matched = array_filter($files, fn($f) => str_contains($f, $sku));
$urls = [];
foreach ($matched as $remotePath) {
$tmp = tempnam(sys_get_temp_dir(), 'ftpimg_');
ftp_get($ftp, $tmp, $remotePath, FTP_BINARY);
$urls[] = $tmp; // шлях до локального файлу
}
ftp_close($ftp);
return $urls;
}
}
Обробка 404 та зламаних посилань
Постачальники періодично видаляють або переносять зображення. Стратегія:
- При 404 — логувати, пропустити, не видаляти вже збережене зображення
- Після 3 неудачних спроб — помічати
source_urlякdead = true - Раз на тиждень — звіт по зламаних посиланнях з пропозицією завантажити зображення вручну
Паралелізм та черги
| Параметр | Значення |
|---|---|
| Чергу | images (окремо від default) |
| Воркерів на чергу | 4–8 |
| Таймаут завдання | 60 сек |
| Розмір чанку URL у Job | 10 штук |
| Повторних спроб | 3 |
При 10 000 зображень з 4 воркерами час повного завантаження — близько 20–40 хвилин (залежить від швидкості хостингу постачальника).
Тривалість реалізації
- Завантаження по HTTP, валідація, конвертація в WebP, збереження — 2 дні
- Кілька розмірів, дедублікація, моніторинг мертвих посилань — +1–2 дні
- FTP-джерело + паралельна чергу + панель прогресу — +1 день







