BitrixNginxPHPSEO

Мультирегиональность для интернет-магазина на Битриксе: 27 городов на поддоменах

Региональность на поддоменах для 27 городов: явный список доменов вместо wildcard, защита от SEO-дублей, возврат пользователя в свой город и изоляция CRM-портала на том же сервере.

Результат27 городов, 0 дублей
Александр Бутаков
Александр Бутаков
обновлено 3 мин чтения

Проблема

Проект на 1С-Битрикс с шаблоном Аспро: Максимум. Хостинг Timeweb Cloud, классическая связка Nginx + Apache. Задача на словах простая: региональные версии сайта для 27 городов России на поддоменах, с нормальным SEO и без поломанного пользовательского пути.

В Аспро региональность вроде бы есть из коробки. Я включил её, развернул поддомены и пошёл проверять. Дальше начался список проблем.

Запросы к новым поддоменам вообще не доходили до сайта. Их перехватывал дефолтный блок Nginx и отдавал заглушку BitrixVM. Попап выбора города подставлял ссылки на основной домен вместо поддоменов. Я долго не мог понять почему. Оказалось, компонент Аспро ждёт определённого заполнения свойств инфоблока, и об этом нигде внятно не написано.

Самое неприятное нашлось потом. Блог и новости компании открывались по всем 27 адресам. Посчитайте сами: каждый раздел умножается на 27 городов. Сотни тысяч дублей, и поисковые роботы тратят краулинговый бюджет на копии одной и той же статьи. За такое Яндекс по голове не погладит.

Ещё пользовательский путь. Человек сидит в каталоге своего города, уходит почитать статью в блог на основном домене, возвращается в каталог. И попадает в каталог основного домена, без своего города. Система про него просто забыла. И мелочь напоследок: ссылки на общие разделы в карточках товаров вели на поддомены и собирали лишние 301-е редиректы. Ссылочный вес утекал на ровном месте.

Пока настраивал региональность для магазина, легла CRM. На этом же сервере, на поддомене crm.example.com, крутится корпоративный портал Битрикс24. Как только я применил базовые настройки инфраструктуры для поддоменов, портал перестал открывать сделки: клик по лиду вешал интерфейс в белый экран, а консоль сыпала ошибками Vue-роутера: AbortError: Transition was skipped. Ядро портала пыталось подключить файлы меню магазина и падало с Fatal Error.

Решение

Начал с инфраструктуры. Настроил Wildcard DNS (*.example.com) и написал в Nginx regex-маску под любой поддомен example.com. Быстро, но именно она и убила CRM: жадный regex перехватывал заодно и crm.example.com, заворачивая его в DOCUMENT_ROOT магазина. Разбираться в проде не хотелось, поэтому убрал маску и прописал строгий список всех 27 городов, явно исключив поддомен CRM.

Заодно передал оригинальный $host при проксировании на Apache. Без этого заголовка ядро Битрикса не понимает, на каком поддомене находится. Одна строчка, а без неё ничего не работает. Дубли закрыл там же, в Nginx: любой запрос к /blog/ или /company/news/ на поддомене получает 301 на главный домен. PHP и база данных в этом даже не участвуют, редирект отрабатывает мгновенно.

Nginx
server {
    server_name example.com www.example.com ekb.example.com spb.example.com krasnodar.example.com; # и ещё 24 города

    location ~ ^/(blog|company/news)/ {
        return 301 https://example.com$request_uri;
    }

    location / {
        proxy_pass http://127.0.0.1:8080;
        proxy_set_header Host $host;
    }
}

Дальше PHP-часть. Всю логику я собрал в один класс с двумя обработчиками событий. Для статьи почистил его: домен заменил на example.com, а там, где нужно вписать своё, оставил TODO-комментарии. Первый метод возвращает пользователя в его город. Если человек оказался на основном домене в обычном разделе вроде каталога, скрипт читает ID региона из куки current_region (её ставит компонент Аспро), достаёт домен региона из инфоблока и делает редирект с сохранением GET-параметров. Фильтры, пагинация и utm-метки не теряются. Второй метод — SEO-патч на событии OnEndBufferContent: регулярные выражения проходят по итоговому HTML перед отдачей браузеру и заменяют региональные ссылки на блог и новости абсолютными ссылками главного домена. Роботы Яндекса и Google видят чистые прямые ссылки, внутри сайта ни одного лишнего редиректа.

Обратите внимание на блоки проверок в начале обоих методов. Они запрещают скриптам срабатывать в админке, AJAX-запросах, REST API и CLI. Проверил на горьком опыте: без этой защиты обработчик ломает импорт.

Файл /local/php_interface/include/regional_handlers.php:

PHP
<?php
/**
 * Класс обработчиков для маршрутизации поддоменов и SEO-оптимизации
 * для мультирегиональных проектов на 1С-Битрикс (Аспро: Максимум)
 */
class RegionalSeoHandlers
{
    /**
     * Возвращает пользователя на региональный поддомен при выходе из общих разделов.
     * Защищает от потери пользовательского контекста (выпадения из региона).
     */
    public static function returnToRegionalDomain()
    {
        // 1. Игнорируем админку, REST API, AJAX и консоль, чтобы не сломать системные процессы
        if (defined('ADMIN_SECTION') && ADMIN_SECTION === true) return;
        if (php_sapi_name() === 'cli') return;
        if (isset($_SERVER['HTTP_BX_AJAX']) || isset($_REQUEST['ajax']) || (isset($_SERVER['HTTP_X_REQUESTED_WITH']) && $_SERVER['HTTP_X_REQUESTED_WITH'] == 'XMLHttpRequest')) return;
        if (strpos($_SERVER['REQUEST_URI'], '/bitrix/') === 0 || strpos($_SERVER['REQUEST_URI'], '/rest/') === 0) return;

        $host = str_replace('www.', '', $_SERVER['HTTP_HOST']);
        $uri = $_SERVER['REQUEST_URI'];

        // TODO: Укажите ваш основной домен (без протокола)
        $mainDomain = 'example.com';

        // 2. Разделы-исключения, которые физически находятся только на главном домене
        // TODO: Перечислите через | директории ваших общих разделов
        $isExcluded = preg_match('#^/(blog|company/news)/#i', $uri);

        // 3. Если пользователь на главном домене, но перешел в обычный раздел (каталог, корзина и т.д.)
        if ($host === $mainDomain && !$isExcluded) {

            // Читаем ID региона напрямую из куки пользователя (устанавливается компонентом Аспро)
            $regionCookieId = isset($_COOKIE['current_region']) ? (int)$_COOKIE['current_region'] : 0;

            if ($regionCookieId > 0 && \Bitrix\Main\Loader::includeModule('iblock')) {

                // Напрямую достаем домен региона из БД инфоблоков
                $dbRegion = \CIBlockElement::GetList(
                    [],
                    ['ID' => $regionCookieId, 'ACTIVE' => 'Y'],
                    false,
                    false,
                    ['ID', 'IBLOCK_ID', 'PROPERTY_MAIN_DOMAIN']
                );

                if ($arRegion = $dbRegion->Fetch()) {
                    $regionDomain = $arRegion['PROPERTY_MAIN_DOMAIN_VALUE'];
                    $regionDomain = trim(str_replace(['http://', 'https://'], '', $regionDomain));

                    // 4. Мгновенный редирект, если домен найден и он отличается от основного
                    if (!empty($regionDomain) && $regionDomain !== $mainDomain) {
                        // Сохраняем GET-параметры (фильтры, пагинация, utm-метки)
                        $queryString = $_SERVER['QUERY_STRING'] ? '?' . $_SERVER['QUERY_STRING'] : '';
                        $newUrl = 'https://' . $regionDomain . current(explode('?', $uri)) . $queryString;

                        LocalRedirect($newUrl, true, '301 Moved Permanently');
                    }
                }
            }
        }
    }

    /**
     * SEO-патч: Принудительно заменяет относительные и ошибочно сгенерированные
     * региональные ссылки на абсолютные ссылки главного домена.
     */
    public static function forceAbsoluteLinksForMainSections(&$content)
    {
        // Пропускаем системные и AJAX запросы
        if (
            (defined('ADMIN_SECTION') && ADMIN_SECTION === true) ||
            php_sapi_name() === 'cli' ||
            strpos($_SERVER['REQUEST_URI'], '/bitrix/') === 0 ||
            strpos($_SERVER['REQUEST_URI'], '/rest/') === 0 ||
            (isset($_SERVER['HTTP_X_REQUESTED_WITH']) && $_SERVER['HTTP_X_REQUESTED_WITH'] == 'XMLHttpRequest') ||
            (isset($_REQUEST['ajax']) && strtolower((string)$_REQUEST['ajax']) === 'y')
        ) {
            return;
        }

        // TODO: Укажите ваш основной домен (с протоколом)
        $mainDomain = 'https://example.com';

        // Правило 1: Ищем относительные ссылки (например, href="/blog/...")
        // TODO: Перечислите ваши общие разделы
        $content = preg_replace(
            '/(href=["\'])\/(blog|company\/news)\//i',
            '$1' . $mainDomain . '/$2/',
            $content
        );

        // Правило 2: Ищем ссылки, куда компонент уже подставил поддомен (href="https://ekb.example.com/blog/...")
        // TODO: Обязательно экранируйте точки в названии вашего домена (например, example\.com)
        $content = preg_replace(
            '/(href=["\'])https?:\/\/[a-zA-Z0-9-]+\.example\.com\/(blog|company\/news)\//i',
            '$1' . $mainDomain . '/$2/',
            $content
        );
    }
}

Подключается это в init.php парой вызовов. Сам init.php я стараюсь держать пустым: в проектах на Битриксе он и так вечно превращается в свалку. Только require и регистрация обработчиков. Изолированный класс проще читать, он переживает обновления ядра и шаблона, и его легко расширять.

Туда же добавил проверку по хосту: если это crm.example.com, обработчики вообще не подключаются. Nginx уже не пускает CRM-трафик в магазин, но лишняя защита в PHP ничего не стоит. А если кто-то потом заведёт на этом сервере ещё один поддомен, она не даст истории повториться.

PHP
<?php
// /local/php_interface/init.php

if (strpos($_SERVER['HTTP_HOST'] ?? '', 'crm.example.com') === false) {
    if (file_exists(__DIR__ . '/include/regional_handlers.php')) {
        require_once(__DIR__ . '/include/regional_handlers.php');

        // Возврат пользователя в свой регион при выходе из глобальных разделов
        AddEventHandler(
            "main",
            "OnPageStart",
            ["RegionalSeoHandlers", "returnToRegionalDomain"]
        );

        // Очистка HTML от региональных ссылок на глобальные разделы
        AddEventHandler(
            "main",
            "OnEndBufferContent",
            ["RegionalSeoHandlers", "forceAbsoluteLinksForMainSections"]
        );
    }
}

Результат

  • 0 контентных дублей — роботы видят чистые прямые ссылки
  • Часть нагрузки переехала с PHP на быстрый Nginx
  • Пользователь свободно ходит между глобальным блогом и каталогом своего города
  • Магазин и CRM живут на одном сервере, не мешая друг другу: Nginx и PHP-ядро держат их порознь
  • Новый город, хоть 28-й, хоть 50-й, добавляется одним элементом инфоблока

Статья была полезной?

Нужна помощь с похожей задачей?

Поддержка сайтов →