Налаштування автоматичного обновлення фідів товарів по розкладу
Товарні фіди — це XML або CSV файли, які потребляють Яндекс.Маркет, Google Merchant, Facebook Catalog, партнерські агрегатори. Якщо фід обновляется вручну або раз на день статичним експортом — актуальність цін та остатків під запитанням. Автоматичний розклад вирішує це системно.
Формати фідів
Кожна платформа чекає свій формат:
- Яндекс.Маркет — YML (Yandex Market Language), розширення XML
-
Google Merchant — RSS 2.0 з розширенням
g:namespace або TSV - Facebook/Instagram — CSV або XML із конкретними полями
- Авито — власний XML
Один і той самий каталог потрібно експортувати в кілька форматів. Архітектура повинна це враховувати з самого початку.
Структура генератора
// app/Services/Feed/FeedGenerator.php
interface FeedGeneratorInterface
{
public function generate(FeedConfig $config): string;
public function format(): string; // 'yml', 'csv', 'xml'
}
class YandexMarketFeedGenerator implements FeedGeneratorInterface
{
public function format(): string { return 'yml'; }
public function generate(FeedConfig $config): string
{
$products = Product::query()
->where('is_active', true)
->whereHas('stock', fn($q) => $q->where('quantity', '>', 0))
->when($config->category_ids, fn($q, $ids) => $q->whereIn('category_id', $ids))
->with(['category', 'images', 'attributes'])
->cursor(); // cursor() — не завантажуємо все в пам'ять
$xml = new \XMLWriter();
$xml->openMemory();
$xml->setIndent(true);
$xml->startDocument('1.0', 'UTF-8');
$xml->startElement('yml_catalog');
$xml->writeAttribute('date', now()->format('Y-m-d H:i'));
$xml->startElement('shop');
$this->writeShopInfo($xml, $config);
$xml->startElement('offers');
foreach ($products as $product) {
$this->writeOffer($xml, $product, $config);
}
$xml->endElement(); // offers
$xml->endElement(); // shop
$xml->endElement(); // yml_catalog
return $xml->outputMemory();
}
private function writeOffer(\XMLWriter $xml, Product $product, FeedConfig $config): void
{
$xml->startElement('offer');
$xml->writeAttribute('id', $product->id);
$xml->writeAttribute('available', $product->stock->quantity > 0 ? 'true' : 'false');
$xml->writeElement('url', route('product.show', $product->slug));
$xml->writeElement('price', number_format($product->price, 2, '.', ''));
$xml->writeElement('currencyId', $config->currency ?? 'RUB');
$xml->writeElement('categoryId', $product->category_id);
$xml->writeElement('name', $product->name);
$xml->writeElement('description', strip_tags($product->description));
foreach ($product->images->take(10) as $image) {
$xml->writeElement('picture', $image->url);
}
$xml->endElement(); // offer
}
}
Модель конфігурації фідів
CREATE TABLE feed_configs (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
type VARCHAR(32) NOT NULL, -- 'yandex', 'google', 'facebook'
schedule VARCHAR(64) NOT NULL, -- cron: '*/30 * * * *'
output_path VARCHAR(512) NOT NULL, -- '/public/feeds/yandex.xml'
is_active BOOLEAN DEFAULT true,
last_run_at TIMESTAMPTZ,
last_error TEXT,
options JSONB DEFAULT '{}'
);
Artisan-команда генерації
// app/Console/Commands/GenerateFeed.php
class GenerateFeed extends Command
{
protected $signature = 'feed:generate {feed_id?} {--all}';
protected $description = 'Generate product feed files';
public function handle(): int
{
$configs = $this->option('all')
? FeedConfig::where('is_active', true)->get()
: FeedConfig::whereKey($this->argument('feed_id'))->get();
foreach ($configs as $config) {
$this->generateOne($config);
}
return self::SUCCESS;
}
private function generateOne(FeedConfig $config): void
{
$start = microtime(true);
try {
$generator = FeedGeneratorFactory::make($config->type);
$content = $generator->generate($config);
// Записуємо в tmp, потім атомарно переіменовуємо
$tmp = $config->output_path . '.tmp';
file_put_contents(public_path($tmp), $content);
rename(public_path($tmp), public_path($config->output_path));
$config->update([
'last_run_at' => now(),
'last_error' => null,
]);
$this->info(sprintf(
'[%s] %s generated in %.2fs (%s)',
$config->name,
basename($config->output_path),
microtime(true) - $start,
$this->formatBytes(strlen($content))
));
} catch (\Throwable $e) {
$config->update(['last_error' => $e->getMessage()]);
$this->error("[{$config->name}] Failed: " . $e->getMessage());
report($e);
}
}
}
Атомарне переіменування важливо: якщо під час записування агрегатор завантажить фід — отримає стару повну версію, а не обрізаний файл в процесі запису.
Планувальник Laravel
// app/Console/Kernel.php
protected function schedule(Schedule $schedule): void
{
// Читаємо розклад з БД — гнучко, без деплою при зміні
FeedConfig::where('is_active', true)->each(function (FeedConfig $config) use ($schedule) {
$schedule->command("feed:generate {$config->id}")
->cron($config->schedule)
->withoutOverlapping(10) // не запускати, якщо попередній ще працює
->runInBackground()
->onFailure(function () use ($config) {
// Оповіщення в Slack/Telegram
Notification::route('slack', config('services.slack.webhook'))
->notify(new FeedGenerationFailed($config));
});
});
}
Типові розклади:
- Ціни та остатки:
*/15 * * * *(щих 15 хвилин) - Основний каталог з описаннями:
0 * * * *(щогодини) - Повний експорт з зображеннями:
0 3 * * *(щоночі)
Мониторинг свіжості
Яндекс.Маркет блокує магазини при фіді старіше 24 годин. Перевіряємо свіжесть:
FeedConfig::where('is_active', true)->each(function (FeedConfig $config) {
$maxAge = $config->options['max_age_minutes'] ?? 60;
$isStale = $config->last_run_at?->diffInMinutes(now()) > $maxAge;
if ($isStale || $config->last_error) {
// Алерт в мониторинг
}
});
Строк реалізації базової системи з двома форматами (YML + Google) та веб-інтерфейсом управління конфігураціями — 3–4 робочі дні.







