Тема
ТЗ — Умный 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_pages | id, category_id, facet_combo (json), slug, status, min_results_ok, last_seen_results_at, lock_version | сгенерированная посадочная страница фильтра |
cms_seo_filter_rules | id, category_id, facet_keys (json), is_active | whitelist допустимых комбинаций измерений |
cms_seo_filter_content | filter_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_version | FormRequest, лимиты длины (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() |
| Ядро: мета/canonical | title/description/canonical страницы фильтра | фильтр seo.meta (канал 2, ревизия п. 11); 410/301 при прополке — RedirectService (п. 12) |
| Filament admin | список страниц/правил со статусами | keyset-JSON через API |
| Подписчики событий | SeoFilterPageGenerated/Pruned/Noindexed | payload по таблице ниже |
Настройки (группа seo-filter)
| Ключ | Тип | Дефолт | affectsPageCache | Описание |
|---|---|---|---|---|
seo-filter.enabled | bool | true | да | Включение генерации посадочных фильтров |
seo-filter.min_results | int | 5 | да | Порог результатов, ниже которого страница уходит в noindex |
seo-filter.max_combo_depth | int | 2 | нет | Максимальное число фасетов в одной комбинации (лимит против комбинаторного взрыва) |
seo-filter.orphan_ttl_days | int | 30 | нет | Через сколько дней без результатов страница помечается orphan |
seo-filter.autogen_texts | bool | true | нет | Автогенерация мета/текста по шаблону при отсутствии ручного оверрайда |
seo-filter.pause_generation | bool | false | да | Kill-switch: аварийная пауза генерации новых страниц без выключения модуля — уже опубликованные страницы остаются |
Лимиты: max_combo_depth — единственный защитный барьер от декартова произведения фасетов; превышение при создании правила отклоняется 422, не тихим обрезанием. Достижение лимита страниц за один rebuild (внутренний батч-чанк) логируется метрикой, не обрывает прогон.
API
| Метод | Путь | Доступ | Назначение |
|---|---|---|---|
| GET | /api/v1/admin/seo-filter/pages | admin (seo-filter.view) | Список сгенерированных страниц со статусами (keyset-пагинация) |
| POST | /api/v1/admin/seo-filter/rules | admin (seo-filter.manage) | Создание/правка whitelist-правила комбинаций |
| POST | /api/v1/admin/seo-filter/pages/{id}/content | admin (seo-filter.manage) | Ручной оверрайд мета/текста страницы (lock_version в теле — 409 при конфликте) |
| POST | /api/v1/admin/seo-filter/rebuild | admin (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 |
SeoFilterPagePruned | orphan-страница снята | filter_page_id, reason |
SeoFilterPageNoindexed | результатов стало меньше min_results | filter_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 присутствует всегда, не мигает между запросами одной и той же страницы.
Крайние случаи и типовые баги
- Взрыв комбинаций (все фасеты × все категории) → ограничен
cms_seo_filter_rules(whitelist) иmax_combo_depth; без правил генерируется ноль страниц, не декартово произведение. - Гонка запросов на несуществующую комбинацию от двух посетителей одновременно → оба получают 404; страницы создаются только плановым
rebuild, не «на лету» по факту запроса. - Конфликт ЧПУ — комбинация фильтра пересекается со slug обычной страницы ядра → маршрутизация ядра проверяет реальные slug раньше
SeoPathParser; пересечение логируется как ошибка конфигурации правила, не 500. - Пагинация в индексации —
?page=2листинга фильтр-страницы не должна плодить отдельные индексируемые URL: canonical всегда указывает на страницу без пагинации. - Suggests-модуль выключен (
cms/seo-engine) → учёт активных URL при обходе не работает, посадочные страницы фильтра продолжают жить и индексироваться независимо. - Модуль выключен посреди
rebuild→Bus::batchостанавливается штатно, уже созданные страницы остаются; следующийrebuildпосле включения продолжает от текущего состояния (идемпотентно, не дублирует). - Гонка пересчёта
last_seen_results_atдвумя параллельными джобами → обновление черезUPDATE ... WHERE id=по факту, не read-modify-write в памяти процесса. - Пустой каталог (0 категорий/фасетов) →
rebuildсоздаёт 0 страниц без ошибок, метрикаseo_filter_pages_generated_total=0. - Огромный каталог (десятки тысяч комбинаций) →
LazyCollection, чанкованные батчи по категориям, без OOM и без блокировки page-cache на время прогона. - Противоречие настроек:
min_results=0приautogen_texts=false→ страница без порога может опубликоваться без текста. ⚠️ Противоречие: страница без title/description будет пойманаcms/seo-engineкак issue «missing meta». Разрешение: приautogen_texts=falseи отсутствии ручного оверрайда страница создаётся в статусеdraft(не публикуется), пока текст не задан вручную. - Измерение city: правило создано без учёта города при включённом
cms/multicity→ комбинация одинакова для всех городов (nullable-измерение, а не отдельная ветка кода); региональные тексты — через оверрайдыcms/multicity, не дублируются здесь. - Конкурентное редактирование — два админа правят текст одной страницы одновременно →
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запрещён