Skip to content

ТЗ — Умный SEO-фильтр (cms/seo-filter)

Слой: 🟡 функц-модуль · Зрелость доноров: ★★★ · Донор: er (er/project/src/app/Domain/Seo/) Статус: ТЗ к разработке

Назначение и возможности

Автоматическая генерация посадочных страниц из осмысленных комбинаций фасетных фильтров каталога/услуг (например «диваны угловые в наличии»). Страница получает собственный ЧПУ, мета-теги и вводный текст, но остаётся проекцией над живыми данными — без дублей контента и без раздувания карты сайта мусорными комбинациями.

  • Правила генерации: whitelist допустимых комбинаций измерений фильтра (не декартово произведение всех фасетов)
  • ЧПУ по шаблону сегментов (/category/svойство-значение/), настраиваемый порядок сегментов
  • Собственные мета-теги и вводный текст на комбинацию (ручной оверрайд + автогенерация по шаблону)
  • Порог min_results: страница с числом товаров ниже порога уходит в noindex или снимается из выдачи
  • Canonical-политика: снятие дублей при пересечении с параметрами сортировки/пагинации
  • Автоматическая «прополка» orphan-страниц (комбинация больше не даёт результатов N дней)
  • Интеграция с sitemap: включение только проиндексированных, невырожденных страниц
  • Журнал изменений позиций (сколько страниц создано/снято за прогон)

Зависимости и выключение

requires: SEO-база ядра, источник фасетов (cms/commerce-facets или cms/services)

Поведение при выключении: посадочные страницы фильтров перестают генерироваться и существующие уходят 410/redirect на родительский листинг по правилам SEO-базы ядра — обычный листинг с фильтрами в URL-параметрах продолжает работать, деградация без 500. Внешних платных API модуль не вызывает — стоимость эксплуатации ограничена вычислительным бюджетом rebuild (см. «Производительность и кеш»), не денежной квотой провайдера.

Модель данных

ТаблицаКлючевые поляПримечание
cms_seo_filter_pagesid, category_id, facet_combo (json), slug, status, min_results_ok, last_seen_results_at, lock_versionсгенерированная посадочная страница фильтра
cms_seo_filter_rulesid, category_id, facet_keys (json), is_activewhitelist допустимых комбинаций измерений
cms_seo_filter_contentfilter_page_id, meta_title, meta_description, intro_text, is_manual_override, lock_versionмета/тексты, ручные правки не затираются автогенерацией

facet_combo/facet_keys — JSONB → GIN; FK constrained() + index(); status — PHP Enum; lock_version — optimistic lock на страницу и на контент (правит редактор конкурентно).

ПДн-паспорт: модуль ПДн не хранит — все три таблицы содержат обезличенный SEO-контент (комбинации фасетов, тексты, статусы), к субъекту персональных данных не привязаны. Ретеншн: строки живут, пока комбинация валидна по правилам; orphan старше orphan_ttl_days удаляется плановой прополкой (см. «Фоновая работа»).

Входные и выходные данные

Входы:

ИсточникПоляЧем валидируется
Событие изменения остатков фасета (каталог/услуги)category_id, facet_key, facet_value, results_countконтракт события подписчика (Event::fake в тестах), неизвестный category_id — пропуск с логом
Форма правила whitelist (Filament)category_id, facet_keys[]FormRequest, facet_keys — whitelist известных ключей фасетов категории
Ручной оверрайд текста (Filament)meta_title, meta_description, intro_text, lock_versionFormRequest, лимиты длины (title ~60, description 160), rich-text санитайзер на intro_text (доверенный HTML — только studio)
Публичный HTTP-запрос ЧПУ фильтра (посетитель)сегменты путиSeoPathParser сверяет сегменты со slug'ами активных cms_seo_filter_pages; неизвестная комбинация → 404, не 500
API rebuild (админ/команда)— (только авторизация)seo-filter.manage, идемпотентная постановка в очередь

Выходы:

ПотребительДанныеФормат
Посетительстраница фильтра (мета, H1, вводный текст, листинг товаров)Blade-рендер темы + SEO-мета ядра
Sitemap ядрасписок URL со status = activeрегистрация в SitemapRegistry (ревизия 14.07.2026, п. 10), тиринг приоритетов
cms/seo-engine (suggests)список активных URL для исключения из orphan-детектавызов публичного сервиса SeoFilterPageService::activeUrls()
Ядро: мета/canonicaltitle/description/canonical страницы фильтрафильтр seo.meta (канал 2, ревизия п. 11); 410/301 при прополке — RedirectService (п. 12)
Filament adminсписок страниц/правил со статусамиkeyset-JSON через API
Подписчики событийSeoFilterPageGenerated/Pruned/Noindexedpayload по таблице ниже

Настройки (группа seo-filter)

КлючТипДефолтaffectsPageCacheОписание
seo-filter.enabledbooltrueдаВключение генерации посадочных фильтров
seo-filter.min_resultsint5даПорог результатов, ниже которого страница уходит в noindex
seo-filter.max_combo_depthint2нетМаксимальное число фасетов в одной комбинации (лимит против комбинаторного взрыва)
seo-filter.orphan_ttl_daysint30нетЧерез сколько дней без результатов страница помечается orphan
seo-filter.autogen_textsbooltrueнетАвтогенерация мета/текста по шаблону при отсутствии ручного оверрайда
seo-filter.pause_generationboolfalseдаKill-switch: аварийная пауза генерации новых страниц без выключения модуля — уже опубликованные страницы остаются

Лимиты: max_combo_depth — единственный защитный барьер от декартова произведения фасетов; превышение при создании правила отклоняется 422, не тихим обрезанием. Достижение лимита страниц за один rebuild (внутренний батч-чанк) логируется метрикой, не обрывает прогон.

API

МетодПутьДоступНазначение
GET/api/v1/admin/seo-filter/pagesadmin (seo-filter.view)Список сгенерированных страниц со статусами (keyset-пагинация)
POST/api/v1/admin/seo-filter/rulesadmin (seo-filter.manage)Создание/правка whitelist-правила комбинаций
POST/api/v1/admin/seo-filter/pages/{id}/contentadmin (seo-filter.manage)Ручной оверрайд мета/текста страницы (lock_version в теле — 409 при конфликте)
POST/api/v1/admin/seo-filter/rebuildadmin (seo-filter.manage)Постановка полного перестроения в очередь

Компоненты

Filament: реестр правил комбинаций, таблица сгенерированных страниц со статусом (активна/noindex/orphan), редактор мета/текста с индикатором ручного оверрайда. Команды: cms:seo-filter:rebuild --json, cms:seo-filter:prune-orphans --json.

Демо-контент: SeoFilterDemoSeeder создаёт 2 демо-категории, 3 whitelist-правила и 5 сгенерированных страниц (в т.ч. одну noindex и одну orphan) — галерея /_gallery и playground показывают модуль без ручного ввода.

Фронтенд-бюджет: страница фильтра переиспользует блок листинга темы целиком — своих JS/CSS модуль не поставляет, CLS не создаёт; тяжёлые виджеты (карты, слайдеры) на этой странице не участвуют.

Эксплуатация (ранбук): метрики seo_filter_pages_generated_total, seo_filter_pages_pruned_total, seo_filter_rebuild_duration_seconds; алерт — rebuild не завершился за плановое окно (health warn через cms/health).

СимптомЧто проверить / команда
Страницы не создаютсяcms:seo-filter:rebuild --dry-run --json — отчёт причин пропуска (нет правила, ниже min_results, превышен max_combo_depth)
Много 404 на старых URL фильтровсверить deprecated_redirect_to и canonical в SEO-базе ядра
Orphan не прополываютсяcms:seo-filter:prune-orphans --json, проверить orphan_ttl_days и отставание очереди seo-filter
Дубли в sitemapпроверить порядок сегментов в SeoCanonicalizer (фиксированный порядок обязателен)

Бэкап/рестор: все три таблицы cms_seo_filter_* попадают в обычный бэкап БД целиком. После рестора денормализованный last_seen_results_at/min_results_ok может отставать — рестор считается завершённым только после контрольного cms:seo-filter:rebuild --dry-run.

События и обмен

СобытиеКогдаPayload
SeoFilterPageGeneratedсоздана новая посадочная страницаfilter_page_id, category_id, slug
SeoFilterPagePrunedorphan-страница снятаfilter_page_id, reason
SeoFilterPageNoindexedрезультатов стало меньше min_resultsfilter_page_id, results_count

Слушает: события изменения фасетов каталога/услуг для пересчёта last_seen_results_at. Своих provides-контрактов не объявляет; встраивается в канонический фильтр ядра seo.meta (мета фасетных страниц) и регистрирует индексируемые URL в SitemapRegistry.

Таблица взаимодействий:

Сущность/модульКаналНаправлениеЧто происходит
cms/commerce-facets / cms/servicesсобытие (1)→ seo-filterпересчёт last_seen_results_at по затронутой категории/фасету
Ядро: seo.meta / SitemapRegistry / RedirectServiceфильтр (2) + контракты (4)seo-filter → ядромета через seo.meta; активные URL — в SitemapRegistry; 410/301 при прополке — RedirectService
cms/seo-engine (suggests)сервис-вызов (4)seo-engine → seo-filterкраулер запрашивает список активных URL, чтобы не считать их orphan
cms/multicity (suggests, если включён)RequestContext (измерение)seo-filter → ядроcity_id-измерение попадает в ключ кеша страницы фильтра как nullable
очередь seo-filterочередь (5)seo-filter → воркерrebuild, плановая прополка, пересчёт min_results

Фоновая работа

Очередь seo-filter: rebuild (полное перестроение), плановая прополка orphan-страниц и пересчёт min_results через ScheduleRegistrar. Джобы идемпотентны — повтор по той же комбинации не создаёт дублей страниц.

Производительность и кеш

Ожидаемые объёмы: тысячи–десятки тысяч сгенерированных страниц на крупном каталоге (категория × 2–3 фасета при max_combo_depth=2). Горячий путь — разрешение URL фильтра на публичном фронте: 0 запросов при page-cache hit; при miss — один запрос по индексу (category_id, facet_combo) (GIN). rebuild обходит категории постранично (keyset, LazyCollection), без загрузки всех комбинаций в память.

Критичные индексы: GIN на facet_combo/facet_keys, (category_id, status), уникальный slug. Собственных тегов кеша нет — только page-cache ядра. min_results, enabled, pause_generation помечены affectsPageCache: изменение сбрасывает кеш затронутых посадочных страниц. При включённом cms/multicity city_id — дополнительное измерение ключа кеша страницы фильтра (nullable, работает и без модуля).

Бюджет запросов на публичный рендер страницы фильтра при cache-miss: один запрос на разрешение facet_combo → сущность страницы плюс стандартный бюджет листинга темы (->with() на карточки товаров, без N+1) — сверх этого модуль не добавляет запросов.

Безопасность

FormRequest на мутациях (rules, content, rebuild); whitelist комбинаций исключает произвольную генерацию по внешнему запросу; ручной оверрайд текста — доверенный HTML только под studio-ролью, ПДн в модели не участвуют. Права: seo-filter.view, seo-filter.manage.

Матрица ролей:

ДействиеАдминистраторМенеджерРедакторStudio
Просмотр списка страниц/правил (view)
Создание/правка whitelist-правил, rebuild (manage)
Ручной оверрайд текста (обычный)
Ручной оверрайд с доверенным HTML в intro_text

UX-требования

Админ: пустое состояние списка правил — «Нет правил — комбинации не генерируются» со ссылкой «Создать правило»; массовое действие «снять с индексации» на списке страниц; человеческая ошибка при дубле правила («Правило для этих фасетов уже существует, id=…», не generic 500); подтверждение перед rebuild (долгая операция, меняет карту сайта).

Посетитель: 404 вместо пустой thin-страницы (не белый экран, не «0 товаров» без объяснения); пагинация и сортировка листинга не создают новых индексируемых URL (canonical на страницу 1); canonical присутствует всегда, не мигает между запросами одной и той же страницы.

Крайние случаи и типовые баги

  1. Взрыв комбинаций (все фасеты × все категории) → ограничен cms_seo_filter_rules (whitelist) и max_combo_depth; без правил генерируется ноль страниц, не декартово произведение.
  2. Гонка запросов на несуществующую комбинацию от двух посетителей одновременно → оба получают 404; страницы создаются только плановым rebuild, не «на лету» по факту запроса.
  3. Конфликт ЧПУ — комбинация фильтра пересекается со slug обычной страницы ядра → маршрутизация ядра проверяет реальные slug раньше SeoPathParser; пересечение логируется как ошибка конфигурации правила, не 500.
  4. Пагинация в индексации?page=2 листинга фильтр-страницы не должна плодить отдельные индексируемые URL: canonical всегда указывает на страницу без пагинации.
  5. Suggests-модуль выключен (cms/seo-engine) → учёт активных URL при обходе не работает, посадочные страницы фильтра продолжают жить и индексироваться независимо.
  6. Модуль выключен посреди rebuildBus::batch останавливается штатно, уже созданные страницы остаются; следующий rebuild после включения продолжает от текущего состояния (идемпотентно, не дублирует).
  7. Гонка пересчёта last_seen_results_at двумя параллельными джобами → обновление через UPDATE ... WHERE id= по факту, не read-modify-write в памяти процесса.
  8. Пустой каталог (0 категорий/фасетов) → rebuild создаёт 0 страниц без ошибок, метрика seo_filter_pages_generated_total=0.
  9. Огромный каталог (десятки тысяч комбинаций) → LazyCollection, чанкованные батчи по категориям, без OOM и без блокировки page-cache на время прогона.
  10. Противоречие настроек: min_results=0 при autogen_texts=false → страница без порога может опубликоваться без текста. ⚠️ Противоречие: страница без title/description будет поймана cms/seo-engine как issue «missing meta». Разрешение: при autogen_texts=false и отсутствии ручного оверрайда страница создаётся в статусе draft (не публикуется), пока текст не задан вручную.
  11. Измерение city: правило создано без учёта города при включённом cms/multicity → комбинация одинакова для всех городов (nullable-измерение, а не отдельная ветка кода); региональные тексты — через оверрайды cms/multicity, не дублируются здесь.
  12. Конкурентное редактирование — два админа правят текст одной страницы одновременно → lock_version на cms_seo_filter_content, конфликт отдаёт 409 с человеческим сообщением, не молчаливый «последний победил».

Донорский код

Что взятьПуть
Домен генерации SEO-страниц по фильтрам, canonical-политикаer/project/src/app/Domain/Seo/

Legacy-импорт: cms:seo-filter:import-legacy --source=<профиль> — маппинг старых URL/мета фильтр-страниц (например таблиц Bitrix smart-filter или прежней инсталляции er) на cms_seo_filter_rules/cms_seo_filter_content по ключу external_id; идемпотентен (повторный прогон обновляет, не дублирует), поддерживает --dry-run с отчётом расхождений. Прогон на копии донорских данных er — часть приёмки модуля.

Тесты и приёмка

  • [ ] Контрактный тест генерации: whitelist-комбинация создаёт страницу с ЧПУ и мета
  • [ ] Страница ниже min_results уходит в noindex автоматически при плановом прогоне
  • [ ] Orphan-страницы старше orphan_ttl_days без результатов прополываются
  • [ ] Canonical корректно схлопывает дубли с сортировкой/пагинацией
  • [ ] При выключении модуля старые URL отдают 410/redirect, не 500
  • [ ] pause_generation (kill-switch) останавливает создание новых страниц, не трогая существующие
  • [ ] Ручной оверрайд мета/текста не перезаписывается автогенерацией при повторном прогоне
  • [ ] Конкурентная правка контента страницы двумя админами отдаёт 409, не «последний победил»
  • [ ] Sitemap включает только status = active и не включает noindex/orphan
  • [ ] cms:seo-filter:import-legacy --dry-run корректно строит отчёт расхождений на копии донора
  • [ ] Матрица ролей: редактор не может создавать правила/rebuild, но может править текст
  • [ ] Контрактный набор cms-testing зелёный, пакет тестируется в testbench-изоляции
  • [ ] Feature-тест на каждый роут API; тестовая БД только seo-filter_test, migrate:fresh запрещён

Внутренняя база знаний студии