Главная/Статьи/Битрикс: динамические robots.txt и sitemap.xml для мультидоменного сайта

Битрикс: динамические robots.txt и sitemap.xml для мультидоменного сайта

Генерация отдельных файлов для каждого поддомена. PHP-обработчики, редиректы через .htaccess, автоматическое определение хоста.

ДМ
Дмитрий Мещеряков
📅 16 октября 2018 г.📖 8 мин чтения

Мультирегиональный сайт на поддоменах — распространённая схема, и в ней есть неприятная асимметрия: контент один, а технические файлы должны быть разными. robots.txt обязан ссылаться на sitemap своего домена, а в sitemap.xml должны быть URL своего поддомена, иначе поисковик получает карту сайта, указывающую на чужой хост.

Решается это генерацией обоих файлов на PHP. Ниже рабочая схема — и три места, где типовая реализация делает хуже, чем было.

Проблема

На мультидоменном сайте (поддомены или разные домены) каждый домен должен иметь свой robots.txt и sitemap.xml с правильными URL. Статические файлы не подходят — нужна динамическая генерация.

Решение

  1. Создать PHP-скрипты для генерации robots и sitemap
  2. Настроить редиректы с .txt и .xml на PHP
  3. Удалить статические файлы

Шаг 1: Динамический robots.txt

Создайте /robots.php:

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

// Хост берём НЕ из запроса напрямую, а сверяем со списком сайтов Битрикса.
// $_SERVER['HTTP_HOST'] — это заголовок, которым управляет клиент
$requestedHost = (string) ($_SERVER['HTTP_HOST'] ?? '');
$allowedHosts = [];

$sites = \CSite::GetList('sort', 'asc', ['ACTIVE' => 'Y']);
while ($site = $sites->Fetch()) {
    if (!empty($site['SERVER_NAME'])) {
        $allowedHosts[] = $site['SERVER_NAME'];
    }
}

if (!in_array($requestedHost, $allowedHosts, true)) {
    header('HTTP/1.1 404 Not Found');
    exit;
}

$protocol = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
$host = $requestedHost;
$baseUrl = $protocol . '://' . $host;

// Заголовок
header('Content-Type: text/plain; charset=UTF-8');
header('X-Robots-Tag: noindex');

// Содержимое robots.txt
echo "User-agent: *\n";
echo "Disallow: /bitrix/\n";
echo "Disallow: /upload/iblock/\n";
echo "Disallow: /*?*\n";
echo "Disallow: /*utm_\n";
echo "Disallow: /*yclid=\n";
echo "Disallow: /*gclid=\n";
echo "Disallow: /search/\n";
echo "Disallow: /auth/\n";
echo "Disallow: /personal/\n";
echo "Disallow: /cart/\n";
echo "Disallow: /order/\n";
echo "Disallow: /compare/\n";
echo "Disallow: /*PAGEN_\n";
echo "Disallow: /*SHOWALL_\n";
echo "Disallow: /*SECTION_CODE=\n";
echo "Disallow: /*ELEMENT_CODE=\n";

echo "\n";
echo "Allow: /bitrix/templates/\n";
echo "Allow: /bitrix/css/\n";
echo "Allow: /bitrix/js/\n";

echo "\n";
echo "Clean-param: utm_source&utm_medium&utm_campaign&utm_content&utm_term\n";
echo "Clean-param: yclid&gclid&fbclid\n";

echo "\n";
echo "Sitemap: {$baseUrl}/sitemap.xml\n";
⚠️ Важно

Директива Host устарела. Яндекс перестал её учитывать ещё в 2018 году: главное зеркало определяется 301-редиректом и тегом canonical, а не строкой в robots.txt. Google её никогда не поддерживал. Оставлять строку не вредно, но она создаёт ложное ощущение, что вопрос с зеркалами решён, — а решается он редиректом на уровне веб-сервера.

⚠️ Важно

Disallow: /*?* — самое опасное правило в этом файле. Оно закрывает от индексации все адреса с параметрами. Вместе с мусорными метками под запрет попадают страницы пагинации, результаты фильтров и любые полезные URL с параметрами, если такие у вас есть.

Плюс оно противоречит директиве Clean-param двумя строками ниже: Clean-param объясняет роботу, какие параметры игнорировать при склейке адресов, но применяется только к страницам, которые роботу разрешено сканировать. Закрытые Disallow страницы он просто не обходит, и Clean-param для них ничего не делает.

Выберите один механизм. Для Яндекса — Clean-param без общего запрета; для Google — canonical на странице, потому что Clean-param он не поддерживает.

💡 Совет

Disallow: /upload/iblock/ закрывает картинки товаров. На интернет-магазине это означает отсутствие в Google Картинках — заметном источнике трафика для товарных сайтов. Правило кочует по шаблонным robots.txt с тех времён, когда в upload/ держали и служебные файлы. Если у вас там только медиа — не закрывайте.

Шаг 2: Динамический sitemap.xml

Простой вариант — обёртка над статическим файлом

php
<?php
// /sitemap.php

$protocol = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
$host = $_SERVER['HTTP_HOST'];

// Путь к статическому sitemap (сгенерированному через админку)
$staticSitemap = $_SERVER['DOCUMENT_ROOT'] . '/sitemap_static.xml';

if (!file_exists($staticSitemap)) {
    header('HTTP/1.1 404 Not Found');
    exit('Sitemap not found');
}

// Читаем и заменяем домен
$content = file_get_contents($staticSitemap);

// Заменяем домен ТОЛЬКО внутри тегов <loc>.
// Наивная замена по всему файлу ломает объявление пространства имён —
// см. предупреждение ниже
$content = preg_replace_callback(
    '#<loc>\s*https?://[^/]+(/[^<]*)</loc>#i',
    static fn(array $m): string => '<loc>' . $protocol . '://' . $host . $m[1] . '</loc>',
    $content
);

header('Content-Type: application/xml; charset=UTF-8');
echo $content;
⚠️ Важно

Почему замену нельзя делать по всему файлу. Шаблон #https?://[^/]+/# попадает не только в <loc>, но и в объявление пространства имён в корневом теге:

xml
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">

После такой замены оно превращается в xmlns="https://moscow.site.ru/schemas/sitemap/0.9", и файл перестаёт быть валидным sitemap: формально XML корректен, но поисковик не распознаёт схему и отклоняет карту целиком. Диагностируется это плохо — в панели вебмастера будет невнятное «не удалось обработать файл», а глазами разница незаметна.

Продвинутый вариант — генерация из базы

php
<?php
// /sitemap.php

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

use Bitrix\Main\Loader;

Loader::includeModule('iblock');

$protocol = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
$host = $_SERVER['HTTP_HOST'];
$baseUrl = $protocol . '://' . $host;

header('Content-Type: application/xml; charset=UTF-8');

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

// Главная
outputUrl($baseUrl . '/', date('c'), 'daily', '1.0');

// Статические страницы
$staticPages = [
    '/about/' => ['weekly', '0.8'],
    '/contacts/' => ['monthly', '0.7'],
    '/services/' => ['weekly', '0.9'],
];

foreach ($staticPages as $url => $params) {
    outputUrl($baseUrl . $url, null, $params[0], $params[1]);
}

// Товары из каталога
$products = \CIBlockElement::GetList(
    ['TIMESTAMP_X' => 'DESC'],
    [
        'IBLOCK_ID' => 2,
        'ACTIVE' => 'Y',
    ],
    false,
    false,
    ['ID', 'TIMESTAMP_X', 'DETAIL_PAGE_URL']
);

while ($product = $products->GetNext(true, false)) {
    outputUrl(
        $baseUrl . $product['DETAIL_PAGE_URL'],
        date('c', strtotime($product['TIMESTAMP_X'])),
        'weekly',
        '0.7'
    );
}

// Разделы каталога
$sections = \CIBlockSection::GetList(
    ['TIMESTAMP_X' => 'DESC'],
    [
        'IBLOCK_ID' => 2,
        'ACTIVE' => 'Y',
    ],
    false,
    ['ID', 'TIMESTAMP_X', 'SECTION_PAGE_URL']
);

while ($section = $sections->GetNext(true, false)) {
    outputUrl(
        $baseUrl . $section['SECTION_PAGE_URL'],
        date('c', strtotime($section['TIMESTAMP_X'])),
        'weekly',
        '0.8'
    );
}

echo '</urlset>';

function outputUrl(string $loc, ?string $lastmod, string $changefreq, string $priority): void
{
    echo '<url>' . "\n";
    echo '  <loc>' . htmlspecialchars($loc) . '</loc>' . "\n";
    
    if ($lastmod) {
        echo '  <lastmod>' . $lastmod . '</lastmod>' . "\n";
    }
    
    echo '  <changefreq>' . $changefreq . '</changefreq>' . "\n";
    echo '  <priority>' . $priority . '</priority>' . "\n";
    echo '</url>' . "\n";
}

Шаг 3: Скрипт конвертации sitemap из админки

После генерации sitemap через админку запустите конвертер:

php
<?php
// /sitemap_convert.php

$sourceFile = $_SERVER['DOCUMENT_ROOT'] . '/sitemap.xml';
$destFile = $_SERVER['DOCUMENT_ROOT'] . '/sitemap_static.xml';

if (!file_exists($sourceFile)) {
    die('Source sitemap not found');
}

// Переименовываем
rename($sourceFile, $destFile);

echo "Converted: sitemap.xml -> sitemap_static.xml\n";
echo "Now /sitemap.xml will use dynamic PHP handler\n";

Шаг 4: Редиректы в .htaccess

apache
# Динамические robots и sitemap
RewriteEngine On

RewriteRule ^robots\.txt$ /robots.php [L]
RewriteRule ^sitemap\.xml$ /sitemap.php [L]
⚠️ Важно

Важно: Удалите статические файлы robots.txt и sitemap.xml из корня сайта, иначе редиректы не сработают.

Шаг 5: Класс для мультидоменного sitemap

php
<?php

namespace Local\Seo;

use Bitrix\Main\Loader;

class DynamicSitemap
{
    private string $baseUrl;
    private array $urls = [];

    public function __construct()
    {
        $protocol = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') 
            ? 'https' : 'http';
        $host = $_SERVER['HTTP_HOST'];
        $this->baseUrl = $protocol . '://' . $host;
    }

    public function addUrl(
        string $path,
        ?string $lastmod = null,
        string $changefreq = 'weekly',
        string $priority = '0.5'
    ): self {
        $this->urls[] = [
            'loc' => $this->baseUrl . $path,
            'lastmod' => $lastmod,
            'changefreq' => $changefreq,
            'priority' => $priority,
        ];
        return $this;
    }

    public function addStaticPages(array $pages): self
    {
        foreach ($pages as $path => $params) {
            $this->addUrl(
                $path,
                null,
                $params['changefreq'] ?? 'weekly',
                $params['priority'] ?? '0.5'
            );
        }
        return $this;
    }

    public function addIblockElements(int $iblockId, ?array $filter = null): self
    {
        Loader::includeModule('iblock');

        $defaultFilter = [
            'IBLOCK_ID' => $iblockId,
            'ACTIVE' => 'Y',
        ];

        $elements = \CIBlockElement::GetList(
            ['TIMESTAMP_X' => 'DESC'],
            array_merge($defaultFilter, $filter ?? []),
            false,
            false,
            ['ID', 'TIMESTAMP_X', 'DETAIL_PAGE_URL']
        );

        while ($el = $elements->GetNext(true, false)) {
            $this->addUrl(
                $el['DETAIL_PAGE_URL'],
                date('c', strtotime($el['TIMESTAMP_X'])),
                'weekly',
                '0.7'
            );
        }

        return $this;
    }

    public function addIblockSections(int $iblockId): self
    {
        Loader::includeModule('iblock');

        $sections = \CIBlockSection::GetList(
            ['TIMESTAMP_X' => 'DESC'],
            ['IBLOCK_ID' => $iblockId, 'ACTIVE' => 'Y'],
            false,
            ['ID', 'TIMESTAMP_X', 'SECTION_PAGE_URL']
        );

        while ($section = $sections->GetNext(true, false)) {
            $this->addUrl(
                $section['SECTION_PAGE_URL'],
                date('c', strtotime($section['TIMESTAMP_X'])),
                'weekly',
                '0.8'
            );
        }

        return $this;
    }

    public function render(): void
    {
        header('Content-Type: application/xml; charset=UTF-8');
        
        echo '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
        echo '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' . "\n";

        foreach ($this->urls as $url) {
            echo '<url>' . "\n";
            echo '  <loc>' . htmlspecialchars($url['loc']) . '</loc>' . "\n";
            
            if ($url['lastmod']) {
                echo '  <lastmod>' . $url['lastmod'] . '</lastmod>' . "\n";
            }
            
            echo '  <changefreq>' . $url['changefreq'] . '</changefreq>' . "\n";
            echo '  <priority>' . $url['priority'] . '</priority>' . "\n";
            echo '</url>' . "\n";
        }

        echo '</urlset>';
    }
}

Использование

php
<?php
// /sitemap.php

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

use Local\Seo\DynamicSitemap;

(new DynamicSitemap())
    ->addUrl('/', null, 'daily', '1.0')
    ->addStaticPages([
        '/about/' => ['changefreq' => 'monthly', 'priority' => '0.8'],
        '/contacts/' => ['changefreq' => 'monthly', 'priority' => '0.7'],
        '/services/' => ['changefreq' => 'weekly', 'priority' => '0.9'],
    ])
    ->addIblockSections(2)
    ->addIblockElements(2)
    ->render();

Итоги

Схема с генерацией на PHP рабочая, но у неё есть цена и есть места, где легко сделать хуже.

Цена. Каждый запрос robots.txt и sitemap.xml теперь поднимает PHP, а на генерации из базы — ещё и обходит каталог. Боты запрашивают эти файлы регулярно, а sitemap.xml большого каталога генерируется секундами. Кэш обязателен: файл на диске с TTL в несколько часов, а не «сгенерируем на каждый запрос».

Чек-лист по итогам разбора:

  1. Хост проверяется по списку сайтов, а не берётся из заголовка запроса.
  2. Директива Host убрана — главное зеркало задаётся редиректом.
  3. Disallow: /*?* и Clean-param не используются вместе — они противоречат друг другу.
  4. Картинки товаров не закрыты от индексации без веской причины.
  5. Замена домена в готовом sitemap идёт только внутри <loc>, а не по всему файлу.
  6. Есть кэш сгенерированных файлов.
  7. Проверено curl-ом с разных хостов: curl -H "Host: spb.site.ru" https://site.ru/robots.txt покажет, что реально отдаётся каждому поддомену, без возни с DNS.
💡 Совет

И самая частая ошибка внедрения: статические robots.txt и sitemap.xml остались лежать в корне. Веб-сервер отдаёт существующий файл, не доходя до правил переадресации на PHP, и вы долго проверяете «почему изменения не применяются». Удалите старые файлы, а не переименовывайте их в robots_old.txt рядом — по этому адресу их тоже кто-нибудь найдёт.

🚀

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

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

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

Комментарии

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