Настройка автоматического обновления фидов товаров по расписанию
Товарные фиды — это 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);
}
}
}
Атомарное переименование (rename) важно: если во время записи агрегатор скачает фид — он получит старую полную версию, а не обрезанный файл в процессе записи.
Планировщик 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 * * *(раз в сутки ночью)
Cron на сервере
# crontab -e
* * * * * www-data php /var/www/project/artisan schedule:run >> /dev/null 2>&1
Публичный доступ к фидам
Фиды хранятся в public/feeds/ и доступны по URL. Для защиты от несанкционированного скачивания добавляют HTTP Basic Auth или signed URL:
Route::get('/feeds/{name}', function (string $name) {
$path = public_path("feeds/{$name}");
abort_unless(file_exists($path), 404);
return response()->file($path, [
'Content-Type' => 'application/xml; charset=utf-8',
'X-Generated' => filemtime($path),
]);
})->middleware('feed.auth'); // опциональная basic auth
Мониторинг свежести
Яндекс.Маркет блокирует магазины при фиде старше 24 часов. Проверяем свежесть:
// Команда в мониторинге или scheduled job
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 рабочих дня.







