Реализация импорта товаров из YML-фида (Яндекс.Маркет формат)
YML (Yandex Market Language) — XML-схема, которую поставщики и производители готовят для размещения на Яндекс.Маркете. Для интернет-магазина это бесценный источник: структурированные данные с ценами, остатками, характеристиками и изображениями, уже прошедшие валидацию поставщика.
Структура YML-фида
Минимальный рабочий пример:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE yml_catalog SYSTEM "shops.dtd">
<yml_catalog date="2024-01-15 10:00">
<shop>
<name>Supplier Shop</name>
<currencies>
<currency id="RUR" rate="1"/>
</currencies>
<categories>
<category id="10">Электроника</category>
<category id="11" parentId="10">Смартфоны</category>
</categories>
<offers>
<offer id="ABC-123" available="true">
<name>Смартфон Example Pro 128GB</name>
<price>29990</price>
<oldprice>34990</oldprice>
<currencyId>RUR</currencyId>
<categoryId>11</categoryId>
<picture>https://cdn.supplier.ru/images/ABC-123_1.jpg</picture>
<picture>https://cdn.supplier.ru/images/ABC-123_2.jpg</picture>
<vendor>Example</vendor>
<vendorCode>PRO128</vendorCode>
<description><![CDATA[Подробное описание...]]></description>
<param name="Диагональ" unit="дюйм">6.7</param>
<param name="Оперативная память" unit="ГБ">8</param>
<param name="Цвет">Чёрный</param>
</offer>
</offers>
</shop>
</yml_catalog>
Потоковый парсер YML
Фиды от крупных поставщиков могут превышать 500 МБ — SimpleXML::load() убьёт PHP по памяти. Парсим через XMLReader:
class YmlFeedParser
{
public function parse(string $url): iterable
{
$context = stream_context_create([
'http' => ['timeout' => 60, 'user_agent' => 'YMLImporter/1.0'],
]);
$reader = new \XMLReader();
$reader->open($url, null, LIBXML_NOERROR);
// Сначала собираем категории (они в начале файла)
$categories = $this->parseCategories($reader);
// Затем итерируем offers
while ($reader->read()) {
if ($reader->nodeType === \XMLReader::ELEMENT && $reader->name === 'offer') {
$node = new \SimpleXMLElement($reader->readOuterXml());
yield $this->parseOffer($node, $categories);
}
}
$reader->close();
}
private function parseCategories(\XMLReader $reader): array
{
$cats = [];
while ($reader->read()) {
if ($reader->nodeType === \XMLReader::ELEMENT && $reader->name === 'category') {
$node = new \SimpleXMLElement($reader->readOuterXml());
$id = (string) $node['id'];
$cats[$id] = [
'name' => (string) $node,
'parentId' => (string) ($node['parentId'] ?? ''),
];
}
// Выходим из блока categories когда дошли до offers
if ($reader->nodeType === \XMLReader::ELEMENT && $reader->name === 'offers') {
break;
}
}
return $cats;
}
private function parseOffer(\SimpleXMLElement $node, array $categories): array
{
$params = [];
foreach ($node->param as $param) {
$params[(string) $param['name']] = [
'value' => (string) $param,
'unit' => (string) ($param['unit'] ?? ''),
];
}
$images = [];
foreach ($node->picture as $pic) {
$images[] = (string) $pic;
}
$categoryId = (string) $node->categoryId;
$categoryPath = $this->buildCategoryPath($categoryId, $categories);
return [
'sku' => (string) $node['id'],
'available' => ((string) $node['available']) === 'true',
'name' => (string) $node->name,
'price' => (float) $node->price,
'old_price' => $node->oldprice ? (float) $node->oldprice : null,
'currency' => (string) $node->currencyId,
'category_id' => $categoryId,
'category_path' => $categoryPath,
'images' => $images,
'vendor' => (string) $node->vendor,
'vendor_code' => (string) $node->vendorCode,
'description' => (string) $node->description,
'params' => $params,
'barcode' => (string) $node->barcode,
];
}
private function buildCategoryPath(string $id, array $cats): string
{
$path = [];
$current = $id;
while ($current && isset($cats[$current])) {
array_unshift($path, $cats[$current]['name']);
$current = $cats[$current]['parentId'];
}
return implode(' > ', $path);
}
}
Типы офферов YML
YML поддерживает несколько типов офферов с разными наборами обязательных полей:
| Тип | Атрибут type |
Дополнительные поля |
|---|---|---|
| Обычный товар | (не указан) | vendor, model |
| Книги | book |
author, publisher, ISBN |
| Аудио/видео | audiobook |
artist, year |
| Лекарства | medicine |
production-line |
| Туры | tour |
country, nights |
Для стандартного каталога электроники/одежды/товаров для дома — тип «обычный товар».
Импорт с маппингом категорий
class YmlImportJob implements ShouldQueue
{
public function handle(
YmlFeedParser $parser,
YmlCategoryMapper $categoryMapper,
ProductImportService $importer,
): void {
foreach ($parser->parse($this->source->url) as $offer) {
if (!$offer['available']) {
// Обновить только остаток = 0, не удалять
$importer->markUnavailable($offer['sku'], $this->source->id);
continue;
}
$siteCategoryId = $categoryMapper->resolve(
$offer['category_id'],
$offer['category_path'],
$this->source->id
);
$importer->upsert(array_merge($offer, [
'site_category_id' => $siteCategoryId,
'source_id' => $this->source->id,
]));
}
}
}
Работа с валютами
YML может содержать цены в разных валютах с курсами:
private function convertToRub(float $price, string $currencyId, array $currencies): float
{
if ($currencyId === 'RUR' || $currencyId === 'RUB') return $price;
$rate = $currencies[$currencyId]['rate'] ?? null;
if (!$rate) {
// Получить актуальный курс из ЦБ РФ
$rate = $this->cbRateProvider->getRate($currencyId);
}
return round($price * $rate, 2);
}
Валидация фида перед импортом
Перед запуском полного импорта — проверить корректность фида:
class YmlFeedValidator
{
public function validate(string $url): ValidationResult
{
$errors = [];
// Проверка DTD
libxml_use_internal_errors(true);
$dom = new \DOMDocument();
$dom->load($url);
$xmlErrors = libxml_get_errors();
libxml_clear_errors();
foreach ($xmlErrors as $error) {
$errors[] = "XML error at line {$error->line}: {$error->message}";
}
// Проверка наличия обязательных элементов
$xpath = new \DOMXPath($dom);
if (!$xpath->query('//offers/offer')->length) {
$errors[] = 'No offers found in feed';
}
return new ValidationResult(empty($errors), $errors);
}
}
Расписание и кэширование фида
YML-фиды могут обновляться от раза в час до раза в сутки. Стратегия кэширования:
private function getFeedPath(string $url): string
{
$cacheKey = 'yml_feed_' . md5($url);
$cached = Storage::path("cache/feeds/{$cacheKey}.xml");
if (file_exists($cached) && filemtime($cached) > time() - $this->cacheTtl) {
return $cached; // используем кэш
}
// Скачать свежий фид
copy($url, $cached);
return $cached;
}
Сроки реализации
- Потоковый парсер YML, базовый импорт цен/остатков/описаний — 2 дня
- Маппинг категорий, конвертация валют, изображения — +1 день
- Валидация фида, кэширование, scheduler, логирование — +1 день







