Реализация автоматического создания карточек товаров из спарсенных данных
Автоматическое создание карточек — финальный этап пайплайна: спарсенные данные от поставщиков или маркетплейсов преобразуются в полноценные товарные карточки в магазине. Задача требует нормализации данных, матчинга с существующим каталогом, создания вариативных товаров и публикации медиа.
Общая схема пайплайна
Raw Scraped Data (JSON)
↓
DataValidator – проверка обязательных полей
↓
DataNormalizer – нормализация полей, единиц, форматов
↓
CategoryMatcher – определение категории по данным
↓
DuplicateChecker – проверка, не существует ли уже такой товар
↓
VariantBuilder – сборка вариативных товаров из размеров/цветов
↓
ImageProcessor – загрузка и обработка фотографий
↓
ProductCreator – запись в БД через репозиторий платформы
↓
SEOGenerator – заполнение мета-тегов, slug
↓
PublicationDecider – draft / published в зависимости от настроек
Валидация входных данных
// app/Services/ProductImport/DataValidator.php
use Illuminate\Support\Facades\Validator;
class DataValidator
{
private array $rules = [
'sku' => 'required|string|max:100',
'name' => 'required|string|max:500',
'price' => 'required|numeric|min:0.01|max:9999999',
'description' => 'nullable|string',
'images' => 'nullable|array',
'images.*' => 'nullable|url',
'specs' => 'nullable|array',
'in_stock' => 'nullable|boolean',
];
public function validate(array $data): ValidationResult
{
$validator = Validator::make($data, $this->rules);
if ($validator->fails()) {
return ValidationResult::fail($validator->errors()->toArray());
}
// Дополнительные бизнес-правила
if (isset($data['price']) && $data['price'] < config('import.min_price', 1)) {
return ValidationResult::fail(['price' => ['Цена подозрительно мала']]);
}
return ValidationResult::pass($validator->validated());
}
}
Матчинг категорий
// app/Services/ProductImport/CategoryMatcher.php
class CategoryMatcher
{
public function match(array $productData): ?int
{
// Стратегия 1: по маппингу поставщик → категория
if ($supplierId = $productData['supplier_id'] ?? null) {
$mapping = SupplierCategoryMapping::where('supplier_id', $supplierId)
->where('supplier_category', $productData['category'] ?? '')
->first();
if ($mapping) return $mapping->local_category_id;
}
// Стратегия 2: нечёткий поиск по названию категории
if ($categoryName = $productData['category'] ?? null) {
$category = Category::where('name', 'ilike', "%{$categoryName}%")->first();
if ($category) return $category->id;
}
// Стратегия 3: по ключевым словам в названии товара
return $this->matchByKeywords($productData['name']);
}
private function matchByKeywords(string $name): ?int
{
$rules = CategoryKeywordRule::orderBy('priority', 'desc')->get();
foreach ($rules as $rule) {
foreach ($rule->keywords as $keyword) {
if (mb_stripos($name, $keyword) !== false) {
return $rule->category_id;
}
}
}
return config('import.default_category_id');
}
}
Детектор дублей
// app/Services/ProductImport/DuplicateChecker.php
class DuplicateChecker
{
public function findExisting(array $data): ?Product
{
// Точное совпадение по SKU поставщика
if ($supplierId = $data['supplier_id'] ?? null) {
$existing = Product::whereHas('supplierMappings', function ($q) use ($data, $supplierId) {
$q->where('supplier_id', $supplierId)
->where('supplier_sku', $data['sku']);
})->first();
if ($existing) return $existing;
}
// Совпадение по EAN/GTIN
if ($gtin = $data['gtin'] ?? null) {
$existing = Product::where('gtin', $gtin)->first();
if ($existing) return $existing;
}
// Нечёткое совпадение по названию + бренду (порог 90%)
if (($name = $data['name'] ?? null) && ($brand = $data['brand'] ?? null)) {
$candidates = Product::where('brand', $brand)
->get(['id', 'name']);
foreach ($candidates as $candidate) {
similar_text(
mb_strtolower($name),
mb_strtolower($candidate->name),
$pct
);
if ($pct >= 90) return $candidate;
}
}
return null;
}
}
Построитель вариативных товаров
// app/Services/ProductImport/VariantBuilder.php
class VariantBuilder
{
/**
* Группирует плоский список товаров в configurable products с вариантами
*
* Пример: 3 строки "Футболка Nike S/красный", "S/синий", "M/красный"
* → 1 configurable product + 3 simple variants
*/
public function buildVariants(array $products): array
{
// Группируем по базовому названию (без размера/цвета)
$groups = [];
foreach ($products as $product) {
$baseKey = $this->extractBaseKey($product);
$groups[$baseKey][] = $product;
}
$result = [];
foreach ($groups as $baseKey => $variants) {
if (count($variants) === 1) {
$result[] = ['type' => 'simple', 'data' => $variants[0]];
} else {
$result[] = [
'type' => 'configurable',
'base' => $this->buildBase($variants),
'variants' => $variants,
];
}
}
return $result;
}
private function extractBaseKey(array $product): string
{
// Убираем из имени паттерны размеров и цветов
$name = preg_replace('/\b(xs|s|m|l|xl|xxl|\d+\s*(см|мм|дюйм))/iu', '', $product['name']);
$name = preg_replace('/\b(красный|синий|чёрный|белый|зелёный|red|blue|black|white)/iu', '', $name);
// Группируем по SKU-префиксу, если доступен
if (preg_match('/^([A-Z0-9]+)-/i', $product['sku'], $m)) {
return strtoupper($m[1]);
}
return trim($product['brand'] ?? '') . '|' . trim($name);
}
}
Основной сервис создания карточки
// app/Services/ProductImport/ProductCreator.php
class ProductCreator
{
public function create(array $data, string $type = 'simple'): Product
{
$slug = $this->generateUniqueSlug($data['name']);
$product = Product::create([
'type' => $type,
'sku' => $data['sku'],
'name' => $data['name'],
'slug' => $slug,
'description' => $data['description'] ?? '',
'price' => $data['price'],
'brand' => $data['brand'] ?? null,
'gtin' => $data['gtin'] ?? null,
'category_id' => $data['category_id'],
'status' => $data['auto_publish'] ? 'active' : 'draft',
'meta_title' => $this->generateMetaTitle($data),
'meta_description' => $this->generateMetaDesc($data),
]);
// Привязка к поставщику
if ($supplierId = $data['supplier_id'] ?? null) {
$product->supplierMappings()->create([
'supplier_id' => $supplierId,
'supplier_sku' => $data['supplier_sku'] ?? $data['sku'],
'supplier_url' => $data['source_url'] ?? null,
]);
}
// Характеристики
if (!empty($data['specs'])) {
foreach ($data['specs'] as $key => $value) {
$attribute = ProductAttribute::firstOrCreate(['code' => $this->slug($key)], ['name' => $key]);
$product->attributeValues()->create([
'attribute_id' => $attribute->id,
'value' => $value,
]);
}
}
// Изображения (через Job)
if (!empty($data['images'])) {
DownloadAndAttachProductImages::dispatch($product->id, $data['images'])
->onQueue('image-processing');
}
return $product;
}
private function generateUniqueSlug(string $name): string
{
$base = \Str::slug($name);
$slug = $base;
$i = 1;
while (Product::where('slug', $slug)->exists()) {
$slug = "{$base}-{$i}";
$i++;
}
return $slug;
}
private function generateMetaTitle(array $data): string
{
$title = $data['name'];
if ($brand = $data['brand'] ?? null) {
$title = "{$brand} {$title}";
}
return mb_substr($title, 0, 70);
}
}
Orchestrating Job
// app/Jobs/CreateProductFromScrapedData.php
class CreateProductFromScrapedData implements ShouldQueue
{
public int $tries = 3;
public function handle(
DataValidator $validator,
CategoryMatcher $matcher,
DuplicateChecker $checker,
ProductCreator $creator
): void {
// Валидация
$result = $validator->validate($this->rawData);
if (!$result->passes()) {
ImportLog::create([
'source' => $this->source,
'sku' => $this->rawData['sku'] ?? 'unknown',
'status' => 'validation_failed',
'errors' => $result->errors(),
]);
return;
}
$data = $result->data();
// Проверка дублей
if ($existing = $checker->findExisting($data)) {
// Обновляем существующий товар, не создаём дубль
$existing->update(['price' => $data['price'], 'in_stock' => $data['in_stock']]);
ImportLog::create(['status' => 'updated', 'product_id' => $existing->id]);
return;
}
// Матчинг категории
$data['category_id'] = $matcher->match($data);
// Создание
$product = $creator->create($data);
ImportLog::create([
'status' => 'created',
'product_id' => $product->id,
'source' => $this->source,
]);
}
}
Срок разработки
| Компонент | Срок |
|---|---|
| Валидатор + нормализатор | 1-2 дня |
| Матчинг категорий | 1-2 дня |
| Детектор дублей | 1 день |
| Построитель вариантов | 2-3 дня |
| Создатель карточек + SEO | 1-2 дня |
| Логирование + дашборд импорта | 1-2 дня |
| Итого | 7-12 рабочих дней |







