Главная/Статьи/Битрикс: собственный модуль генерации sitemap с разбиением

Битрикс: собственный модуль генерации sitemap с разбиением

Разработка модуля для создания sitemap с ограничением 4000 URL на файл. Без правок ядра, с админкой в стиле SEO-модуля.

ДМ
Дмитрий Мещеряков
📅 2 июня 2026 г.📖 9 мин чтения

Штатный SEO-модуль Битрикса генерирует sitemap одним файлом. Пока в каталоге пара тысяч товаров, это никого не беспокоит. На каталоге в двести тысяч позиций начинается интересное: генерация упирается в лимиты PHP, файл весит десятки мегабайт, а в Search Console вместо понятного отчёта — «не удалось обработать».

Ниже — модуль, который генерирует sitemap с разбиением на части, живёт в /local/modules/ и не трогает ядро.

Проблема

Формально ограничения sitemap такие: не более 50 000 URL и не более 50 МБ в несжатом виде на файл. Это спецификация, а не рекомендация, и её соблюдают все поисковые системы.

💡 Совет

Про «4000 URL для лучшей индексации». Такой рекомендации у Google нет — цифра кочует по SEO-статьям без источника, и опираться на неё как на требование поисковика не стоит. Разбивать файлы действительно полезно, но по инженерным причинам, а не в надежде на индексацию:

  • память и время генерации — сборка одного файла на 200 тысяч URL упирается в memory_limit гораздо раньше, чем в лимит спецификации;
  • диагностика — в Search Console статистика показывается по каждому файлу отдельно, и разбиение по разделам сразу показывает, где именно просели страницы;
  • частичная регенерация — при изменениях в одном инфоблоке перегенерировать нужно только его файлы.

Поэтому лимит в настройках профиля — это ваш инженерный выбор, а не соответствие чужому требованию.

Решение

Создаём независимый модуль в /local/modules/, который:

  • Генерирует sitemap с разбиением по заданному лимиту
  • Имеет админку в стиле штатного SEO-модуля
  • Не затрагивает ядро Битрикса

Структура модуля

text
/local/modules/client.sitemapgenerator/
├── include.php
├── install/
│   ├── index.php
│   └── db/
│       └── install.sql
├── lib/
│   ├── Profile/
│   │   └── ProfileTable.php
│   ├── Generator/
│   │   ├── SitemapGenerator.php
│   │   └── XmlWriter.php
│   └── Service/
│       └── IndexBuilder.php
├── admin/
│   ├── menu.php
│   └── sitemap_list.php
└── lang/
    └── ru/

ORM-таблица профилей

php
<?php
// /local/modules/client.sitemapgenerator/lib/Profile/ProfileTable.php

namespace Client\SitemapGenerator\Profile;

use Bitrix\Main\ORM\Data\DataManager;
use Bitrix\Main\ORM\Fields;

class ProfileTable extends DataManager
{
    public static function getTableName(): string
    {
        return 'client_sitemap_profiles';
    }

    public static function getMap(): array
    {
        return [
            new Fields\IntegerField('ID', [
                'primary' => true,
                'autocomplete' => true,
            ]),
            new Fields\StringField('NAME', [
                'required' => true,
            ]),
            new Fields\StringField('SITE_ID', [
                'required' => true,
                'size' => 2,
            ]),
            new Fields\IntegerField('URLS_PER_FILE', [
                'default_value' => 4000,
            ]),
            new Fields\TextField('IBLOCK_IDS'),
            new Fields\StringField('FILENAME_TEMPLATE', [
                'default_value' => 'sitemap-{type}-{id}-{part}.xml',
            ]),
            new Fields\BooleanField('ACTIVE', [
                'default_value' => 'Y',
            ]),
            new Fields\DatetimeField('LAST_RUN'),
            new Fields\IntegerField('LAST_URLS_COUNT'),
        ];
    }
}

Генератор sitemap

php
<?php
// /local/modules/client.sitemapgenerator/lib/Generator/SitemapGenerator.php

namespace Client\SitemapGenerator\Generator;

use Bitrix\Main\Loader;
use Client\SitemapGenerator\Profile\ProfileTable;

class SitemapGenerator
{
    private array $profile;
    private int $urlsPerFile;
    private string $siteUrl;
    private array $files = [];

    public function __construct(int $profileId)
    {
        Loader::includeModule('iblock');
        
        $this->profile = ProfileTable::getById($profileId)->fetch();
        
        if (!$this->profile) {
            throw new \RuntimeException("Profile #{$profileId} not found");
        }
        
        $this->urlsPerFile = (int) $this->profile['URLS_PER_FILE'] ?: 4000;
        $this->siteUrl = $this->getSiteUrl();
    }

    /**
     * Генерация sitemap для всех инфоблоков профиля
     */
    public function generate(): array
    {
        // В новом коде храните список как JSON: unserialize над значением
        // из базы — лишний риск и нечитаемые данные при отладке
        $iblockIds = json_decode((string) $this->profile['IBLOCK_IDS'], true) ?: [];
        $totalUrls = 0;

        foreach ($iblockIds as $iblockId) {
            $urls = $this->collectIblockUrls((int) $iblockId);
            $totalUrls += count($urls);
            
            $this->writeChunkedSitemap($urls, 'iblock', $iblockId);
        }

        // Статические страницы
        $staticUrls = $this->collectStaticUrls();
        $totalUrls += count($staticUrls);
        $this->writeChunkedSitemap($staticUrls, 'static', 0);

        // Обновляем профиль
        ProfileTable::update($this->profile['ID'], [
            'LAST_RUN' => new \Bitrix\Main\Type\DateTime(),
            'LAST_URLS_COUNT' => $totalUrls,
        ]);

        // Генерируем индексный файл
        $this->generateIndex();

        return [
            'total_urls' => $totalUrls,
            'files' => $this->files,
        ];
    }

    /**
     * Сбор URL из инфоблока
     *
     * ВНИМАНИЕ: метод собирает все URL в память. Для каталога до нескольких
     * десятков тысяч элементов это приемлемо; на большем объёме переходите
     * к потоковой записи — см. раздел «Ограничение по памяти» ниже
     */
    private function collectIblockUrls(int $iblockId): array
    {
        $urls = [];

        // Разделы
        $sections = \CIBlockSection::GetList(
            ['SORT' => 'ASC'],
            ['IBLOCK_ID' => $iblockId, 'ACTIVE' => 'Y'],
            false,
            ['ID', 'TIMESTAMP_X', 'SECTION_PAGE_URL']
        );

        while ($section = $sections->GetNext(true, false)) {
            $urls[] = [
                'loc' => $this->siteUrl . $section['SECTION_PAGE_URL'],
                'lastmod' => $this->formatDate($section['TIMESTAMP_X']),
                'changefreq' => 'weekly',
                'priority' => '0.8',
            ];
        }

        // Элементы
        $elements = \CIBlockElement::GetList(
            ['TIMESTAMP_X' => 'DESC'],
            ['IBLOCK_ID' => $iblockId, 'ACTIVE' => 'Y'],
            false,
            false,
            ['ID', 'TIMESTAMP_X', 'DETAIL_PAGE_URL']
        );

        while ($element = $elements->GetNext(true, false)) {
            $urls[] = [
                'loc' => $this->siteUrl . $element['DETAIL_PAGE_URL'],
                'lastmod' => $this->formatDate($element['TIMESTAMP_X']),
                'changefreq' => 'weekly',
                'priority' => '0.7',
            ];
        }

        return $urls;
    }

    /**
     * Запись URL с разбиением на файлы
     */
    private function writeChunkedSitemap(array $urls, string $type, int $id): void
    {
        if (empty($urls)) {
            return;
        }

        $chunks = array_chunk($urls, $this->urlsPerFile);
        $totalParts = count($chunks);

        foreach ($chunks as $partIndex => $chunk) {
            $partNumber = $partIndex + 1;
            
            $filename = $this->buildFilename($type, $id, $partNumber, $totalParts);
            $filepath = $_SERVER['DOCUMENT_ROOT'] . '/' . $filename;

            $this->writeXmlFile($filepath, $chunk);
            $this->files[] = $filename;
        }
    }

    /**
     * Формирование имени файла
     */
    private function buildFilename(string $type, int $id, int $part, int $total): string
    {
        $template = $this->profile['FILENAME_TEMPLATE'];
        
        // Если только одна часть — не добавляем номер
        $partSuffix = $total > 1 ? "-{$part}" : '';
        
        return strtr($template, [
            '{type}' => $type,
            '{id}' => $id ?: 'pages',
            '{part}' => $partSuffix,
        ]);
    }

    /**
     * Запись XML-файла
     */
    private function writeXmlFile(string $filepath, array $urls): void
    {
        $xml = '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
        $xml .= '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' . "\n";

        foreach ($urls as $url) {
            $xml .= '<url>' . "\n";
            $xml .= '  <loc>' . htmlspecialchars($url['loc']) . '</loc>' . "\n";
            
            if (!empty($url['lastmod'])) {
                $xml .= '  <lastmod>' . $url['lastmod'] . '</lastmod>' . "\n";
            }
            
            $xml .= '  <changefreq>' . $url['changefreq'] . '</changefreq>' . "\n";
            $xml .= '  <priority>' . $url['priority'] . '</priority>' . "\n";
            $xml .= '</url>' . "\n";
        }

        $xml .= '</urlset>';

        // Пишем через временный файл: rename в пределах одной ФС атомарен,
        // и робот никогда не получит наполовину записанный XML
        $tmp = $filepath . '.tmp';
        file_put_contents($tmp, $xml);
        rename($tmp, $filepath);
    }

    /**
     * Генерация индексного файла
     */
    private function generateIndex(): void
    {
        $indexPath = $_SERVER['DOCUMENT_ROOT'] . '/sitemap.xml';

        $xml = '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
        $xml .= '<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' . "\n";

        foreach ($this->files as $file) {
            $xml .= '<sitemap>' . "\n";
            $xml .= '  <loc>' . $this->siteUrl . '/' . $file . '</loc>' . "\n";
            $xml .= '  <lastmod>' . date('c') . '</lastmod>' . "\n";
            $xml .= '</sitemap>' . "\n";
        }

        $xml .= '</sitemapindex>';

        $tmp = $indexPath . '.tmp';
        file_put_contents($tmp, $xml);
        rename($tmp, $indexPath);
    }

    private function getSiteUrl(): string
    {
        $site = \CSite::GetByID($this->profile['SITE_ID'])->Fetch();
        $protocol = ($site['HTTPS'] ?? 'N') === 'Y' ? 'https' : 'http';
        return $protocol . '://' . $site['SERVER_NAME'];
    }

    private function formatDate(string $date): string
    {
        return date('c', strtotime($date));
    }

    private function collectStaticUrls(): array
    {
        // Можно расширить: парсинг .menu.php, UrlRewrite и т.д.
        return [
            [
                'loc' => $this->siteUrl . '/',
                'changefreq' => 'daily',
                'priority' => '1.0',
            ],
            [
                'loc' => $this->siteUrl . '/about/',
                'changefreq' => 'monthly',
                'priority' => '0.8',
            ],
            [
                'loc' => $this->siteUrl . '/contacts/',
                'changefreq' => 'monthly',
                'priority' => '0.7',
            ],
        ];
    }
}
⚠️ Важно

Индексный файл перезаписывается целиком. generateIndex() перечисляет только файлы текущего запуска ($this->files). Если профилей несколько и они запускаются по очереди, каждый следующий сотрёт из /sitemap.xml упоминания предыдущего — в индексе останется только последний. Для нескольких профилей индекс нужно собирать по всем существующим файлам sitemap на диске, а не по результату одного прогона.

Ограничение по памяти

Главное ограничение этой реализации: collectIblockUrls() складывает все URL в массив, и только потом array_chunk режет его на части. Для каталога на 200 тысяч позиций в памяти окажется 200 тысяч массивов по четыре элемента — сотни мегабайт, и это до того, как начнётся запись.

Ирония в том, что модуль писался как раз ради больших каталогов. Правильная схема для них — потоковая: открыть файл, идти по выборке, писать каждый URL сразу и закрывать файл по достижении лимита.

php
$file = null;
$count = 0;
$part  = 1;

$elements = \CIBlockElement::GetList(
    ['ID' => 'ASC'],
    ['IBLOCK_ID' => $iblockId, 'ACTIVE' => 'Y'],
    false,
    false,
    ['ID', 'TIMESTAMP_X', 'DETAIL_PAGE_URL']
);

while ($element = $elements->GetNext(true, false)) {
    if ($count % $this->urlsPerFile === 0) {
        if ($file) {
            $this->closeSitemapFile($file);
        }
        $file = $this->openSitemapFile($type, $iblockId, $part++);
    }

    fwrite($file, $this->renderUrlNode($element));
    $count++;
}

if ($file) {
    $this->closeSitemapFile($file);
}

Потребление памяти при таком подходе не зависит от размера каталога: в каждый момент в памяти одна запись плюс буфер файла.

💡 Совет

Сортировка по ID ASC, а не по TIMESTAMP_X DESC. При длинной выборке с сортировкой по изменяемому полю элемент, отредактированный во время генерации, может переехать между страницами выборки — и попасть в файл дважды или не попасть вообще. Сортировка по неизменяемому первичному ключу от этого защищает.

Консольный запуск

php
<?php
// /local/cli/generate_sitemap.php

require_once $_SERVER['DOCUMENT_ROOT'] . '/bitrix/modules/main/include/prolog_before.php';

use Bitrix\Main\Loader;
use Client\SitemapGenerator\Generator\SitemapGenerator;

Loader::includeModule('client.sitemapgenerator');

$profileId = (int) ($argv[1] ?? 1);

try {
    $generator = new SitemapGenerator($profileId);
    $result = $generator->generate();
    
    echo "Sitemap generated successfully!\n";
    echo "Total URLs: {$result['total_urls']}\n";
    echo "Files created:\n";
    
    foreach ($result['files'] as $file) {
        echo "  - {$file}\n";
    }
} catch (\Exception $e) {
    echo "Error: " . $e->getMessage() . "\n";
    exit(1);
}

Что положить в sitemap, а что нет

Техническая часть решена, но качество sitemap определяется не ей. Файл — это заявление «вот страницы, которые я считаю важными», и поисковик сверяет его с тем, что видит сам. Расхождения превращаются в предупреждения в Search Console.

Только канонические URL, отдающие 200. Страница, закрытая в robots.txt, с noindex или с canonical на другой адрес, в sitemap не нужна — это прямое противоречие вашим же указаниям.

Никакой пагинации, фильтров и сортировок. В sitemap идут страницы разделов и карточки, а не варианты их отображения.

lastmod должен быть честным. Дата реального изменения контента полезна: поисковик использует её при планировании обхода. Дата генерации файла, проставленная всем страницам разом, — бесполезна и постепенно обесценивает поле для вашего сайта.

changefreq и priority можно не заполнять. Google официально их игнорирует уже много лет. Вреда от них нет, но и смысла тоже — это просто лишние байты в каждом из ста файлов.

Неактивные и скрытые элементы не попадают автоматически. В выборке выше есть ACTIVE => 'Y', но нет проверки на дату активности (ACTIVE_FROM/ACTIVE_TO) и на права доступа. Товар с закончившейся активностью отдаст 404, а ссылка на него в sitemap останется.

Результат

После генерации:

text
/sitemap.xml                    # Индексный файл
/sitemap-iblock-2-1.xml         # Каталог, часть 1 (4000 URL)
/sitemap-iblock-2-2.xml         # Каталог, часть 2 (4000 URL)
/sitemap-iblock-2-3.xml         # Каталог, часть 3 (остаток)
/sitemap-iblock-5.xml           # Новости (< 4000 URL, без разбиения)
/sitemap-static-pages.xml       # Статические страницы
💡 Совет

Совет: Добавьте модуль в cron для автоматической генерации: 0 3 * * * php /home/bitrix/www/local/cli/generate_sitemap.php 1

⚠️ Важно

Важно: При большом каталоге (100k+ товаров) генерация может занять несколько минут. Запускайте в ночное время или через очередь.

🚀

Хотите такое же решение?

Настрою окружение под ваш проект, учту специфику инфраструктуры и обучу команду.

Обсудить проект →
Бесплатная консультация · Ответ в течение дня

Комментарии

Система комментариев скоро будет подключена. А пока вы можете написать мне в Telegram или на email.