Задача из категории «доработать чужое решение, не сломав его при обновлении». 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 вперемешку с чужим кодом. Для одноразовой правки это оверинжиниринг; для схемы, которую вы поставите нескольким клиентам, — нет.
Структура модуля
/local/modules/client.smartseo/
├── include.php
├── install/index.php
└── lib/
├── Bootstrap.php
├── Orm/
│ └── SitemapSettingsTable.php
└── Service/
└── RegionalSitemapManager.phpORM-таблица настроек
<?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
// /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
// /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'];
}
}Результат
После генерации:
/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-страницы попадают в индекс своих поддоменов. Что важно сделать при внедрении:
- Зафиксировать версию SmartSEO, на которой всё проверено, и перепроверять после каждого обновления модуля.
- Настроить мониторинг свежести региональных файлов — это решение ломается молча.
- Прописать региональные sitemap в
robots.txtкаждого поддомена и добавить их в панели вебмастеров: без явного указания поисковик может их не найти. - Проверить автоочистку при удалении региона, иначе файлы удалённых поддоменов останутся отдаваться.
И общий принцип для доработок чужих модулей: любое решение, встроенное рядом с вендорским кодом, обязано либо громко падать, либо иметь мониторинг. Тихая деградация — худший сценарий: система выглядит работающей, а результат её работы месяцами никто не проверяет.
Комментарии
Система комментариев скоро будет подключена. А пока вы можете написать мне в Telegram или на email.