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

Битрикс: региональные sitemap для умного фильтра SmartSEO

Модуль генерации sitemap для каждого поддомена без правок ядра Aspro. Proxy-файлы, htaccess, автоочистка.

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

Задача из категории «доработать чужое решение, не сломав его при обновлении». SmartSEO от Aspro умеет генерировать sitemap для SEO-страниц умного фильтра, но знает только про основной домен. На мультирегиональном проекте с поддоменами это означает, что региональные версии SEO-страниц поисковикам просто не видны.

Править модуль вендора нельзя — обновление затрёт правки. Значит, нужно встроиться рядом: дождаться, пока SmartSEO сделает свою работу, и размножить результат по регионам.

Проблема

SmartSEO от Aspro генерирует sitemap для SEO-страниц умного фильтра, но использует только основной домен. При мультирегиональности на поддоменах (moscow.site.ru, spb.site.ru) поисковые роботы не видят региональные версии.

Решение

Модуль client.smartseo, который:

  • Отслеживает генерацию sitemap в SmartSEO
  • Создаёт копии для каждого поддомена
  • Настраивает proxy-файлы и htaccess
  • Автоматически очищает устаревшие файлы
💡 Совет

Почему отдельный модуль, а не обработчик в init.php. Логика тут не в чистоте архитектуры, а в переносимости: у модуля есть установка и удаление, своя таблица настроек и версия. Решение переезжает на другой проект копированием директории, а не выковыриванием кусков из init.php вперемешку с чужим кодом. Для одноразовой правки это оверинжиниринг; для схемы, которую вы поставите нескольким клиентам, — нет.

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

text
/local/modules/client.smartseo/
├── include.php
├── install/index.php
└── lib/
    ├── Bootstrap.php
    ├── Orm/
    │   └── SitemapSettingsTable.php
    └── Service/
        └── RegionalSitemapManager.php

ORM-таблица настроек

php
<?php
// /local/modules/client.smartseo/lib/Orm/SitemapSettingsTable.php

namespace Client\SmartSeo\Orm;

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

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

    public static function getMap(): array
    {
        return [
            new Fields\IntegerField('ID', [
                'primary' => true,
                'autocomplete' => true,
            ]),
            new Fields\IntegerField('SMARTSEO_PROFILE_ID', [
                'required' => true,
            ]),
            new Fields\BooleanField('GENERATE_REGIONAL', [
                'default_value' => 'N',
            ]),
            new Fields\TextField('SUBDOMAINS'), // JSON: ["moscow", "spb", "ekb"]
            new Fields\DatetimeField('LAST_GENERATED'),
        ];
    }

    /**
     * Получить настройки по профилю SmartSEO
     */
    public static function getByProfile(int $profileId): ?array
    {
        return self::getRow([
            'filter' => ['SMARTSEO_PROFILE_ID' => $profileId],
        ]);
    }
}

Менеджер региональных sitemap

php
<?php
// /local/modules/client.smartseo/lib/Service/RegionalSitemapManager.php

namespace Client\SmartSeo\Service;

use Client\SmartSeo\Orm\SitemapSettingsTable;

class RegionalSitemapManager
{
    private const REGIONAL_DIR = '/aspro_regions/sitemap/';
    private string $baseDomain;
    private array $subdomains = [];

    public function __construct(string $baseDomain)
    {
        $this->baseDomain = $baseDomain;
    }

    /**
     * Установить список поддоменов
     */
    public function setSubdomains(array $subdomains): self
    {
        $this->subdomains = $subdomains;
        return $this;
    }

    /**
     * Генерация региональных копий sitemap
     */
    public function generateRegionalCopies(string $originalFilePath): array
    {
        if (!file_exists($originalFilePath)) {
            throw new \RuntimeException("Original sitemap not found: {$originalFilePath}");
        }

        $this->ensureDirectoryExists();
        
        $originalContent = file_get_contents($originalFilePath);
        $basename = pathinfo($originalFilePath, PATHINFO_FILENAME);
        $createdFiles = [];

        foreach ($this->subdomains as $subdomain) {
            $regionalHost = $subdomain . '.' . $this->baseDomain;
            $regionalContent = $this->replaceHost($originalContent, $regionalHost);
            
            $regionalFilename = "{$basename}_{$regionalHost}.xml";
            $regionalPath = $_SERVER['DOCUMENT_ROOT'] . self::REGIONAL_DIR . $regionalFilename;
            
            file_put_contents($regionalPath, $regionalContent);
            $createdFiles[$regionalHost] = $regionalFilename;
        }

        // Создаём proxy-файл
        $this->createProxyFile($basename, $createdFiles);
        
        // Обновляем htaccess
        $this->updateHtaccess($basename);

        return $createdFiles;
    }

    /**
     * Замена домена в XML
     */
    private function replaceHost(string $content, string $newHost): string
    {
        // Заменяем в <loc>
        $pattern = '#(<loc>https?://)([^/]+)(/.+?</loc>)#';
        
        return preg_replace_callback($pattern, function($matches) use ($newHost) {
            return $matches[1] . $newHost . $matches[3];
        }, $content);
    }

    /**
     * Создание proxy-файла
     */
    private function createProxyFile(string $basename, array $files): void
    {
        $proxyPath = $_SERVER['DOCUMENT_ROOT'] . "/{$basename}.php";
        
        $filesJson = json_encode($files, JSON_UNESCAPED_UNICODE);
        
        $proxyContent = <<<PHP
<?php
// Regional sitemap proxy - auto-generated
\$host = \$_SERVER['HTTP_HOST'];
\$files = {$filesJson};

if (isset(\$files[\$host])) {
    \$filepath = \$_SERVER['DOCUMENT_ROOT'] . '/aspro_regions/sitemap/' . \$files[\$host];
    
    if (file_exists(\$filepath)) {
        header('Content-Type: application/xml; charset=UTF-8');
        readfile(\$filepath);
        exit;
    }
}

// Fallback to original
\$original = \$_SERVER['DOCUMENT_ROOT'] . '/{$basename}.xml';
if (file_exists(\$original)) {
    header('Content-Type: application/xml; charset=UTF-8');
    readfile(\$original);
}
PHP;

        file_put_contents($proxyPath, $proxyContent);
    }

    /**
     * Обновление htaccess для проксирования
     */
    private function updateHtaccess(string $basename): void
    {
        $htaccessPath = $_SERVER['DOCUMENT_ROOT'] . '/.htaccess';
        $content = file_exists($htaccessPath) ? file_get_contents($htaccessPath) : '';
        
        $marker = "# BEGIN regional-sitemap-{$basename}";
        $endMarker = "# END regional-sitemap-{$basename}";
        
        // Удаляем старый блок
        $pattern = "/\n?" . preg_quote($marker) . ".*?" . preg_quote($endMarker) . "\n?/s";
        $content = preg_replace($pattern, '', $content);
        
        // Добавляем новый блок
        $rule = <<<HTACCESS

{$marker}
RewriteCond %{HTTP_HOST} !^www\\.
RewriteCond %{HTTP_HOST} ^(.+)\\.{$this->baseDomain}\$
RewriteRule ^{$basename}\\.xml\$ /{$basename}.php [L]
{$endMarker}
HTACCESS;

        // Вставляем после RewriteEngine On
        if (strpos($content, 'RewriteEngine On') !== false) {
            $content = str_replace('RewriteEngine On', "RewriteEngine On\n{$rule}", $content);
        } else {
            $content .= $rule;
        }
        
        file_put_contents($htaccessPath, $content);
    }

    /**
     * Очистка при удалении sitemap
     */
    public function cleanup(string $basename): void
    {
        // Удаляем региональные файлы
        $dir = $_SERVER['DOCUMENT_ROOT'] . self::REGIONAL_DIR;
        $files = glob($dir . $basename . '_*.xml');
        
        foreach ($files as $file) {
            unlink($file);
        }
        
        // Удаляем proxy
        $proxyPath = $_SERVER['DOCUMENT_ROOT'] . "/{$basename}.php";
        if (file_exists($proxyPath)) {
            unlink($proxyPath);
        }
        
        // Очищаем htaccess
        $this->removeHtaccessBlock($basename);
    }

    private function removeHtaccessBlock(string $basename): void
    {
        $htaccessPath = $_SERVER['DOCUMENT_ROOT'] . '/.htaccess';
        
        if (!file_exists($htaccessPath)) {
            return;
        }
        
        $content = file_get_contents($htaccessPath);
        $marker = "# BEGIN regional-sitemap-{$basename}";
        $endMarker = "# END regional-sitemap-{$basename}";
        
        $pattern = "/\n?" . preg_quote($marker) . ".*?" . preg_quote($endMarker) . "\n?/s";
        $content = preg_replace($pattern, '', $content);
        
        file_put_contents($htaccessPath, $content);
    }

    private function ensureDirectoryExists(): void
    {
        $dir = $_SERVER['DOCUMENT_ROOT'] . self::REGIONAL_DIR;
        
        if (!is_dir($dir)) {
            mkdir($dir, 0755, true);
        }
    }
}

Перехват генерации SmartSEO

Самая хрупкая часть решения — и о её хрупкости нужно сказать прямо.

php
<?php
// /local/modules/client.smartseo/lib/Bootstrap.php

namespace Client\SmartSeo;

use Bitrix\Main\EventManager;
use Client\SmartSeo\Service\RegionalSitemapManager;
use Client\SmartSeo\Orm\SitemapSettingsTable;

class Bootstrap
{
    public static function init(): void
    {
        // Перехватываем завершение генерации через shutdown
        EventManager::getInstance()->addEventHandler(
            'main',
            'OnAfterEpilog',
            [self::class, 'checkSitemapGeneration']
        );
    }

    public static function checkSitemapGeneration(): void
    {
        $request = \Bitrix\Main\Application::getInstance()->getContext()->getRequest();
        $uri = $request->getRequestUri();
        
        // Проверяем, что это запрос генерации SmartSEO sitemap
        if (strpos($uri, 'aspro_smartseo_sitemap') === false) {
            return;
        }
        
        // Получаем ID профиля из запроса
        $profileId = (int) $request->get('profile_id');
        
        if ($profileId <= 0) {
            return;
        }
        
        // Проверяем настройки
        $settings = SitemapSettingsTable::getByProfile($profileId);
        
        if (!$settings || $settings['GENERATE_REGIONAL'] !== 'Y') {
            return;
        }
        
        $subdomains = json_decode($settings['SUBDOMAINS'], true) ?: [];
        
        if (empty($subdomains)) {
            return;
        }
        
        // Запускаем генерацию региональных копий
        register_shutdown_function(function() use ($profileId, $subdomains) {
            self::generateRegionalSitemaps($profileId, $subdomains);
        });
    }

    private static function generateRegionalSitemaps(int $profileId, array $subdomains): void
    {
        // Получаем путь к сгенерированному sitemap
        $sitemapPath = self::getSitemapPath($profileId);
        
        if (!$sitemapPath || !file_exists($sitemapPath)) {
            return;
        }
        
        $baseDomain = self::getBaseDomain();
        
        $manager = new RegionalSitemapManager($baseDomain);
        $manager->setSubdomains($subdomains);
        $manager->generateRegionalCopies($sitemapPath);
        
        // Обновляем время генерации
        SitemapSettingsTable::update(
            SitemapSettingsTable::getByProfile($profileId)['ID'],
            ['LAST_GENERATED' => new \Bitrix\Main\Type\DateTime()]
        );
    }

    private static function getSitemapPath(int $profileId): ?string
    {
        // Путь зависит от версии SmartSEO — сверьтесь со своей установкой.
        // Возвращаем null, если файла нет: молчаливая работа с
        // несуществующим путём здесь хуже явного отказа
        $path = $_SERVER['DOCUMENT_ROOT'] . "/sitemap_smartseo_{$profileId}.xml";

        return file_exists($path) ? $path : null;
    }

    private static function getBaseDomain(): string
    {
        $site = \CSite::GetByID(SITE_ID)->Fetch();
        return $site['SERVER_NAME'];
    }
}

Результат

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

text
/sitemap_smartseo_1.xml                          # Основной файл
/sitemap_smartseo_1.php                          # Proxy
/aspro_regions/sitemap/
    sitemap_smartseo_1_moscow.site.ru.xml
    sitemap_smartseo_1_spb.site.ru.xml
    sitemap_smartseo_1_ekb.site.ru.xml

При запросе moscow.site.ru/sitemap_smartseo_1.xml → отдаётся региональная версия с URL-ами на moscow.site.ru.

Чем платим за такое решение

Это надстройка над чужим модулем, и у неё есть встроенные риски. Перечислю честно — они не отменяют решение, но определяют, как его сопровождать.

Зависимость от внутренностей SmartSEO. Путь к файлам, момент вызова, формат — всё это может измениться в очередном обновлении Aspro. Причём сломается оно тихо: sitemap для основного домена продолжит генерироваться, а региональные копии просто перестанут появляться. Внешне сайт исправен, а из индекса постепенно уходят региональные страницы.

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

Дублирование данных. Каждый регион — отдельная копия того же sitemap с заменённым доменом. На десяти регионах и крупном каталоге это десятки мегабайт файлов, которые нужно чистить при удалении региона (в модуле для этого предусмотрена автоочистка — проверьте, что она реально отрабатывает).

План на случай отказа. Держите в голове запасной вариант: если SmartSEO после обновления станет несовместим, региональные sitemap можно собирать полностью самостоятельно из тех же SEO-страниц, не перехватывая ничего. Дороже в поддержке, зато не ломается от чужих релизов.

Итоги

Схема рабочая и решает конкретную задачу: региональные SEO-страницы попадают в индекс своих поддоменов. Что важно сделать при внедрении:

  1. Зафиксировать версию SmartSEO, на которой всё проверено, и перепроверять после каждого обновления модуля.
  2. Настроить мониторинг свежести региональных файлов — это решение ломается молча.
  3. Прописать региональные sitemap в robots.txt каждого поддомена и добавить их в панели вебмастеров: без явного указания поисковик может их не найти.
  4. Проверить автоочистку при удалении региона, иначе файлы удалённых поддоменов останутся отдаваться.
⚠️ Важно

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

🚀

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

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

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

Комментарии

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