Штатный 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-модуля
- Не затрагивает ядро Битрикса
Структура модуля
/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
// /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
// /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 сразу и закрывать файл по достижении лимита.
$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
// /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 останется.
Результат
После генерации:
/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.