Реализация автоматического скачивания и загрузки изображений товаров
При массовом импорте товаров изображения — самая объёмная и трудоёмкая часть. Поставщик присылает ссылки или пути в прайсе, а магазин должен скачать, оптимизировать и сохранить файлы в собственном хранилище. Делать это руками при каталоге от 1 000 позиций нереально.
Откуда берутся ссылки на изображения
-
В CSV/Excel — колонка с URL или относительным путём:
https://supplier.ru/images/ABC-123_1.jpg -
В XML/YML — теги
<picture>или<image> -
В API-ответе — массив
images: [{url, sort, is_main}] - На FTP поставщика — файлы лежат в директории, имя файла = артикул
Архитектура pipeline скачивания
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 MB
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 - Раз в неделю — отчёт по dead-ссылкам с предложением загрузить изображение вручную
Параллелизм и очереди
| Параметр | Значение |
|---|---|
| Очередь | images (отдельная от default) |
| Воркеры на очередь | 4–8 |
| Таймаут задачи | 60 сек |
| Размер чанка URL в Job | 10 штук |
| Повторных попыток | 3 |
При 10 000 изображений с 4 воркерами время полного скачивания — около 20–40 минут (зависит от скорости хостинга поставщика).
Сроки реализации
- Скачивание по HTTP, валидация, конвертация в WebP, сохранение — 2 дня
- Несколько размеров, дедупликация, мониторинг мёртвых ссылок — +1–2 дня
- FTP-источник + параллельная очередь + дашборд прогресса — +1 день







