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

URL: https://dmeshcheryakov.ru/blog/bitrix-smartfilter-url-validation/
Раздел: 1С-Битрикс
Теги: 1С-Битрикс, SEO, Smart Filter, Кэширование
Опубликовано: 2026-05-23
Обновлено: 2026-09-07
Автор: Дмитрий Мещеряков (https://dmeshcheryakov.ru)

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

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

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

## Почему любой URL умного фильтра отдаёт 200

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

```
/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 и лимит частоты для ботов.