Главная/Статьи/Битрикс: валидация URL умного фильтра для SEO

Битрикс: валидация URL умного фильтра для SEO

Как невалидные URL smart.filter раздувают кэш и вредят SEO. Проверка сегментов, 404 для несуществующих комбинаций.

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

Умный фильтр устроен так, что любая последовательность сегментов в адресе разбирается как условия фильтрации, а результат — даже пустой — отдаётся с кодом 200 OK. Для сайта с каталогом это означает бесконечное множество валидных с точки зрения сервера адресов, ведущих в никуда.

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

Проблема

Умный фильтр Битрикса генерирует ЧПУ-адреса для комбинаций свойств:

text
/catalog/kontejnery/brand-is-tara_ru/
/catalog/kontejnery/prop_shirina-from-330/
/catalog/kontejnery/brand-is-tara_ru/prop_shirina-from-100-to-500/

Проблема: любой URL возвращает 200 OK, даже если комбинация не существует или пуста. Это приводит к:

  • SEO-проблемам: дубли контента, размытие релевантности, потеря краулингового бюджета
  • Росту кэша: каждый URL кэшируется отдельно
  • Нагрузке: боты индексируют бесконечные комбинации

Решение: валидация URL

Проверяем URL перед обработкой и возвращаем 404 для невалидных комбинаций.

💡 Совет

Ключевой принцип — белый список. Валидатор разрешает то, что явно описано: существующие коды свойств, реальные значения, разумные границы диапазонов. Попытка перечислить «плохие» варианты бессмысленна: их бесконечно много, в этом и состоит исходная проблема.

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

Класс валидатора

php
<?php
// /local/php_interface/classes/Catalog/SmartFilterValidator.php

namespace Local\Catalog;

use Bitrix\Main\Loader;

class SmartFilterValidator
{
    private int $iblockId;
    private array $validPropertyCodes = [];
    private array $validBrands = [];
    private array $validSections = [];

    public function __construct(int $iblockId)
    {
        Loader::includeModule('iblock');
        $this->iblockId = $iblockId;
        $this->loadValidData();
    }

    /**
     * Проверка текущего запроса
     */
    public static function validateCurrentRequest(): void
    {
        $requestPath = self::getRequestPath();

        // Паттерн URL каталога с фильтром
        if (!preg_match('~^/catalog/([^/]+)/(.+?)/?$~u', $requestPath, $matches)) {
            return;
        }

        $sectionCode = $matches[1];
        $filterTail = trim($matches[2], '/');

        if (empty($filterTail)) {
            return;
        }

        // Разбиваем на сегменты
        $segments = self::splitFilterSegments($filterTail);

        // Проверяем, что это URL фильтра
        if (!self::hasFilterHints($segments)) {
            return;
        }

        // Получаем ID инфоблока по разделу
        $iblockId = self::getIblockIdBySection($sectionCode);
        
        if (!$iblockId) {
            self::trigger404();
            return;
        }

        // Валидируем сегменты
        $validator = new self($iblockId);
        
        if (!$validator->validateSegments($segments)) {
            self::trigger404();
        }
    }

    /**
     * Валидация сегментов фильтра
     */
    public function validateSegments(array $segments): bool
    {
        foreach ($segments as $segment) {
            if (!$this->validateSegment($segment)) {
                return false;
            }
        }
        return true;
    }

    /**
     * Валидация одного сегмента
     */
    private function validateSegment(string $segment): bool
    {
        // Бренд: brand-is-{code}
        if (preg_match('/^brand-is-(.+)$/', $segment, $m)) {
            return $this->isValidBrand($m[1]);
        }

        // Свойство с точным значением: prop_{code}-is-{value}
        if (preg_match('/^prop_([^-]+)-is-(.+)$/', $segment, $m)) {
            return $this->isValidPropertyValue($m[1], $m[2]);
        }

        // Свойство с диапазоном: prop_{code}-from-{min}[-to-{max}]
        if (preg_match('/^prop_([^-]+)-from-(\d+)(?:-to-(\d+))?$/', $segment, $m)) {
            return $this->isValidPropertyRange($m[1], (int)$m[2], isset($m[3]) ? (int)$m[3] : null);
        }

        // Цена: price-from-{min}[-to-{max}]
        if (preg_match('/^price-from-(\d+)(?:-to-(\d+))?$/', $segment)) {
            return true; // Цена всегда валидна
        }

        // Неизвестный формат — невалидно
        return false;
    }

    /**
     * Проверка валидности бренда
     */
    private function isValidBrand(string $code): bool
    {
        return in_array($code, $this->validBrands, true);
    }

    /**
     * Проверка валидности значения свойства
     */
    private function isValidPropertyValue(string $propertyCode, string $value): bool
    {
        $propertyCode = strtoupper($propertyCode);
        
        if (!isset($this->validPropertyCodes[$propertyCode])) {
            return false;
        }

        // Проверяем существование значения
        $property = $this->validPropertyCodes[$propertyCode];
        
        if ($property['TYPE'] === 'L') {
            // Для списков проверяем XML_ID варианта
            return $this->isValidEnumValue($property['ID'], $value);
        }

        return true; // Для других типов просто проверяем наличие свойства
    }

    /**
     * Проверка валидности диапазона свойства
     */
    private function isValidPropertyRange(string $propertyCode, int $from, ?int $to): bool
    {
        $propertyCode = strtoupper($propertyCode);
        
        if (!isset($this->validPropertyCodes[$propertyCode])) {
            return false;
        }

        // Проверяем логичность диапазона
        if ($to !== null && $from > $to) {
            return false;
        }

        return true;
    }

    /**
     * Загрузка валидных данных из БД
     */
    private function loadValidData(): void
    {
        // Свойства инфоблока
        $properties = \CIBlockProperty::GetList(
            ['SORT' => 'ASC'],
            ['IBLOCK_ID' => $this->iblockId, 'ACTIVE' => 'Y']
        );

        while ($prop = $properties->Fetch()) {
            $this->validPropertyCodes[strtoupper($prop['CODE'])] = [
                'ID' => $prop['ID'],
                'TYPE' => $prop['PROPERTY_TYPE'],
                'USER_TYPE' => $prop['USER_TYPE'],
            ];
        }

        // Бренды (если есть highload-блок или свойство)
        $this->loadValidBrands();
    }

    /**
     * Загрузка валидных брендов
     */
    private function loadValidBrands(): void
    {
        // Вариант 1: Бренды в свойстве типа "Список"
        if (isset($this->validPropertyCodes['BRAND'])) {
            $enums = \CIBlockPropertyEnum::GetList(
                ['SORT' => 'ASC'],
                ['PROPERTY_ID' => $this->validPropertyCodes['BRAND']['ID']]
            );

            while ($enum = $enums->Fetch()) {
                $this->validBrands[] = $enum['XML_ID'] ?: \CUtil::translit(
                    $enum['VALUE'],
                    'ru',
                    ['replace_space' => '_', 'replace_other' => '_']
                );
            }
        }

        // Вариант 2: Highload-блок (расширить по необходимости)
    }

    /**
     * Проверка значения списка
     */
    private function isValidEnumValue(int $propertyId, string $xmlId): bool
    {
        $enum = \CIBlockPropertyEnum::GetList(
            [],
            ['PROPERTY_ID' => $propertyId, 'XML_ID' => $xmlId]
        )->Fetch();

        return !empty($enum);
    }

    /**
     * Разбивка URL на сегменты фильтра
     */
    private static function splitFilterSegments(string $tail): array
    {
        return array_filter(explode('/', $tail));
    }

    /**
     * Проверка наличия паттернов фильтра
     */
    private static function hasFilterHints(array $segments): bool
    {
        foreach ($segments as $segment) {
            if (preg_match('/(?:-is-|^prop_|^price-|^brand-)/', $segment)) {
                return true;
            }
        }
        return false;
    }

    /**
     * Получение пути запроса
     */
    private static function getRequestPath(): string
    {
        $uri = $_SERVER['REQUEST_URI'] ?? '';
        $path = parse_url($uri, PHP_URL_PATH);
        return $path ?: '/';
    }

    /**
     * Получение ID инфоблока по коду раздела
     */
    private static function getIblockIdBySection(string $sectionCode): ?int
    {
        $section = \CIBlockSection::GetList(
            [],
            ['CODE' => $sectionCode, 'ACTIVE' => 'Y'],
            false,
            ['ID', 'IBLOCK_ID']
        )->Fetch();

        return $section ? (int)$section['IBLOCK_ID'] : null;
    }

    /**
     * Выдача 404 ошибки
     */
    private static function trigger404(): void
    {
        if (!defined('ERROR_404')) {
            define('ERROR_404', 'Y');
        }

        \Bitrix\Iblock\Component\Tools::process404(
            '',
            true,
            true,
            true,
            '/404.php'
        );
        exit;
    }
}

Подключение валидации

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

use Bitrix\Main\EventManager;

EventManager::getInstance()->addEventHandler(
    'main',
    'OnProlog',
    function() {
        // Только для фронтенда
        if (defined('ADMIN_SECTION') && ADMIN_SECTION === true) {
            return;
        }
        
        \Local\Catalog\SmartFilterValidator::validateCurrentRequest();
    }
);

Результаты

До валидацииПосле валидации
/catalog/chairs/brand-is-fake123/ → 200 OK (пустая страница)→ 404
/catalog/chairs/prop_width-from-999999/ → 200 OK→ 404
/catalog/chairs/random-garbage/ → 200 OK→ 404
/catalog/chairs/brand-is-ikea/ → 200 OK (есть товары)→ 200 OK ✓

Дополнительно: проверка наличия товаров

php
/**
 * Проверка, что комбинация фильтра даёт результаты
 */
public function hasProducts(array $filter): bool
{
    $element = \CIBlockElement::GetList(
        [],
        array_merge($filter, [
            'IBLOCK_ID' => $this->iblockId,
            'ACTIVE' => 'Y',
        ]),
        false,
        ['nTopCount' => 1],
        ['ID']
    )->Fetch();

    return !empty($element);
}
⚠️ Важно

Проверка «есть ли товары» — отдельное решение, и по умолчанию я бы его не включал.

Технически всё верно: запрос с nTopCount => 1 дёшев. Проблема в смысле. Пустая выдача фильтра — не то же самое, что несуществующий адрес: товар мог закончиться на складе, его могли временно деактивировать, поставка приедет завтра. Отдав 404, вы выбьете из индекса страницу, которая через неделю снова будет с товарами, и возвращать её придётся заново.

Разумная граница: 404 — за синтаксически невалидные адреса (несуществующее свойство, значение вне справочника, бессмысленный диапазон), пустая страница с текстом «по вашему запросу ничего не найдено» и noindex — за валидные, но пустые комбинации. Первое означает «такого не бывает», второе — «сейчас пусто».

💡 Совет

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

Порядок внедрения

Валидатор — инструмент, который выдаёт 404 на боевом каталоге. Ошибка в нём стоит трафика, поэтому включать его нужно поэтапно.

  1. Режим наблюдения. Валидатор работает, но вместо 404 пишет в лог: «признал бы невалидным такой-то адрес». Сутки-двое такого лога дают полную картину.
  2. Разбор лога. В нём не должно быть адресов из sitemap, из внутренних ссылок и из отчёта по страницам с трафиком в панели вебмастера. Если такие есть — правила слишком строгие.
  3. Включение 404 и наблюдение за отчётом об индексировании ближайшие недели.

Быстрая проверка после включения:

bash
# Раздел и валидный фильтр — 200
curl -o /dev/null -s -w "%{http_code} %{url_effective}\n" \
  https://site.ru/catalog/kontejnery/ \
  https://site.ru/catalog/kontejnery/brand-is-tara_ru/

# Несуществующее свойство и абсурдный диапазон — 404
curl -o /dev/null -s -w "%{http_code} %{url_effective}\n" \
  https://site.ru/catalog/kontejnery/prop_nesushchestvuet-is-x/ \
  https://site.ru/catalog/kontejnery/prop_shirina-from-999999/

Итоги

Валидация URL умного фильтра решает три задачи разом: чистый индекс, сэкономленный краулинговый бюджет и остановленный рост кэша каталога. Что важно при внедрении:

  • Белый список, источник — настройки каталога. Захардкоженный массив значений устареет и начнёт отдавать 404 на живых страницах.
  • Проверка до тяжёлой работы. Смысл не только в коде ответа: под ботовой нагрузкой важнее, что запросы к базе вообще не выполнятся.
  • 404 — за невалидный синтаксис, noindex — за пустую выдачу. Это разные ситуации с разными последствиями.
  • Поэтапное включение через режим наблюдения. Валидатор, выбивший из индекса работающие разделы, обходится дороже проблемы, которую он решал.
  • Это половина решения. Вторая половина — политика кэширования для тяжёлых URL и лимит частоты для ботов.
🚀

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

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

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

Комментарии

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