Реализация импорта товаров из YML-фида (Яндекс.Маркет формат)

Наша компания занимается разработкой, поддержкой и обслуживанием сайтов любой сложности. От простых одностраничных сайтов до масштабных кластерных систем построенных на микро сервисах. Опыт разработчиков подтвержден сертификатами от вендоров.

Разработка и обслуживание любых видов сайтов:

Информационные сайты или веб-приложения
Сайты визитки, landing page, корпоративные сайты, онлайн каталоги, квиз, промо-сайты, блоги, новостные ресурсы, информационные порталы, форумы, агрегаторы
Сайты или веб-приложения электронной коммерции
Интернет-магазины, B2B-порталы, маркетплейсы, онлайн-обменники, кэшбэк-сайты, биржи, дропшиппинг-платформы, парсеры товаров
Веб-приложения для управления бизнес-процессами
CRM-системы, ERP-системы, корпоративные порталы, системы управления производством, парсеры информации
Сайты или веб-приложения электронных услуг
Доски объявлений, онлайн-школы, онлайн-кинотеатры, конструкторы сайтов, порталы предоставления электронных услуг, видеохостинги, тематические порталы

Это лишь некоторые из технических типов сайтов, с которыми мы работаем, и каждый из них может иметь свои специфические особенности и функциональность, а также быть адаптированным под конкретные потребности и цели клиента

Предлагаемые услуги
Показано 1 из 1 услугВсе 2065 услуг
Реализация импорта товаров из YML-фида (Яндекс.Маркет формат)
Средняя
~3-5 рабочих дней
Часто задаваемые вопросы

Наши компетенции:

Этапы разработки

Последние работы

  • image_website-b2b-advance_0.png
    Разработка сайта компании B2B ADVANCE
    1262
  • image_web-applications_feedme_466_0.webp
    Разработка веб-приложения для компании FEEDME
    1171
  • image_websites_belfingroup_462_0.webp
    Разработка веб-сайта для компании БЕЛФИНГРУПП
    874
  • image_ecommerce_furnoro_435_0.webp
    Разработка интернет магазина для компании FURNORO
    1094
  • image_crm_enviok_479_0.webp
    Разработка веб-приложения для компании Enviok
    831
  • image_bitrix-bitrix-24-1c_fixper_448_0.png
    Разработка веб-сайта для компании ФИКСПЕР
    851

Реализация импорта товаров из 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 день