Проблема
Проект на 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 и база данных в этом даже не участвуют, редирект отрабатывает мгновенно.
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
/**
* Класс обработчиков для маршрутизации поддоменов и 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
// /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-й, добавляется одним элементом инфоблока