besnovatyj / yii2-cms-sitemap
Карта сайта для Yii2 CMS: собирает адреса модулей через контракт SitemapProvider в XML-карту (с индексом и нарезкой по лимитам протокола) и человеческую HTML-карту. Ни одного контентного модуля не знает поимённо.
Package info
github.com/besnovatyj/yii2-cms-sitemap
Type:yii2-extension
pkg:composer/besnovatyj/yii2-cms-sitemap
Requires
- php: >=8.4
- ext-json: *
- ext-xmlwriter: *
- besnovatyj/yii2-cms-contracts: ^1.0
- besnovatyj/yii2-cms-kernel: ^1.0
- yiisoft/yii2: ~2.0.0
Requires (Dev)
- roave/security-advisories: dev-latest
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Карта сайта для Yii2 CMS: XML-карта для поисковых систем (/sitemap.xml + файлы разделов) и
человеческая карта /sitemap.
Модуль не знает ни одного контентного модуля поимённо. Адреса приходят от модулей, которые
реализовали нейтральный контракт Besnovatyj\Contracts\sitemap\SitemapProvider, — тем же приёмом,
что источники сквозного поиска и цели меню. Выключили модуль в менеджере — его раздел исчез из
карты сам, без правок здесь.
Что делает
- собирает разделы модулей в XML-карту, режет по лимитам протокола (50 000 адресов / 50 МБ на файл) и пишет индекс;
- из того же прохода строит дерево HTML-карты — XML и HTML не могут разойтись;
- строит адреса через
frontendUrlManager, поэтому короткие URL модуля алиасов учитываются автоматически; - хранит артефакты в
@runtime/sitemap(вне корня сайта) и отдаёт их без единого запроса к базе; - проверяет, объявлена ли карта в
robots.txt, и показывает готовую строку.
Миграций и таблиц у модуля нет: всё состояние — файлы, которые полностью восстанавливаются одной командой.
Как подключить модуль к карте
use Besnovatyj\Contracts\sitemap\ChangeFrequency; use Besnovatyj\Contracts\sitemap\SitemapProvider; use Besnovatyj\Contracts\sitemap\SitemapSection; use Besnovatyj\Contracts\sitemap\SitemapUrl; class Module extends CmsModule implements SitemapProvider { public function sitemapSections(): array { return [ new SitemapSection( key: 'shop.product', // стабильный ключ: попадает в имя файла карты label: 'Товары', changeFrequency: ChangeFrequency::Daily, priority: 0.7, icon: 'bi bi-box-seam', ), ]; } public function sitemapUrls(string $section): iterable { return match ($section) { 'shop.product' => (new ProductReadRepository())->sitemapUrls(), default => [], // неизвестный ключ — пустой итератор, не исключение }; } }
А сам поток адресов — генератором, из readModels (только публичное!):
public function sitemapUrls(): iterable { foreach (Product::find()->visible()->each(200) as $product) { yield new SitemapUrl( route: '/Shop/product/view', // роут, а НЕ готовый URL params: ['slug' => $product->slug], title: $product->name, // нужен человеческой карте lastModified: strtotime($product->updated_at) ?: null, ); } }
Тяжёлому разделу стоит добавить SitemapFreshness — тогда неизменившийся раздел не перечитывается:
public function sitemapRevision(string $section): ?string { $row = Product::find()->visible()->select(['n' => 'COUNT(*)', 'm' => 'MAX(updated_at)'])->asArray()->one(); return $row['n'] . ':' . $row['m']; // именно пара: MAX(updated_at) не замечает удаления }
Соглашения, по которым подключены штатные модули
Так устроены провайдеры в page, blog, actors, documents, gallery, performance;
новому модулю проще повторить схему, чем изобретать свою.
- Раздел — единица нарезки файлов, включения в настройках и блока на человеческой карте.
Поэтому у модуля их обычно два: навигационная ветка (
blog.taxonomy,documents.category) и сами записи (blog.post,documents.document). - Список раздела (
/blog,/actors) — первый адрес навигационной секции сpriority: 0.9. Заводить под один адрес отдельный раздел значит засорить и настройки, и индекс файлов. - Массовые записи — только в XML (
inHtmlMap: falseу секции): сотни статей или приказов превратили бы оглавление сайта в ленту. Спектакли, актёры, альбомы, наоборот, на карте полезны — их немного и они читаются как оглавление. lastModified— дата изменения записи (updated_at), а не дата события (премьеры, приказа): краулера интересует, поменялась ли сама страница.depthотдаётся как есть — сборщик сам сдвигает глубину так, чтобы верхний уровень раздела начинался с нуля; провайдеру не нужно знать, есть ли у его дерева корень-обёртка.- Роуты — те же, что строят вьюхи сайта (
Url::to(['view', 'id' => …])) и отдаёт сквозной поиск: тогда починка URL-правил модуля исправляет все каналы разом. sitemapRevision()только у сущностей сupdated_at. Деревьям разделов отпечаток не нужен: колонок времени там нет, а суррогат не заметил бы переименования; их полный обход дёшев.
Эксплуатация
php yii Sitemap/build/run # собрать заново (крон, деплой)
php yii Sitemap/build/status # что собрано и когда
На боевом сервере — от пользователя веб-сервера: sudo -u www-data php yii Sitemap/build/run.
Иначе процесс не прочитает секреты базы, а созданные им файлы окажутся недоступны на запись
веб-процессу. Крон — раз в сутки:
0 4 * * * www-data cd /path/to/app && php yii Sitemap/build/run >/dev/null
Если крон не настроен, карта чинит себя сама: устаревшую (старше ttl) или отсутствующую
пересоберёт первый же запрос — под замком, поэтому одновременных сборок не бывает. Совпадение
крона с заходом краулера или нажатием кнопки — штатная ситуация, а не ошибка: второй запуск
получает BuildInProgressException, консоль завершается кодом TEMPFAIL (75), админка
показывает предупреждение, а сайт продолжает отдавать прошлую карту.
Сборка атомарна: каждый файл пишется во временный и переименовывается, манифест — последним,
и только потом удаляются осиротевшие файлы. Читатель никогда не видит полусобранной карты.
Артефакты — XML и JSON, ни одного .php: на боевом сервере opcache.validate_timestamps=0, и
PHP-файл, перезаписанный консолью, веб-процесс не перечитал бы до перезагрузки FPM.
Файлы отдаёт контроллер через sendFile(). Если нужен ноль PHP на запросах краулера, достаточно
location-секции в nginx с alias на @runtime/sitemap — код при этом не меняется.
Ссылка на человеческую карту добавляется в меню штатно: модуль отдаёт цель Sitemap/map/index
модулю меню (пока HTML-карта включена в настройках — пункт, ведущий на 404, не предлагается).
В robots.txt карту нужно объявить руками — модуль не правит файл скелета приложения, но
показывает готовую строку на своей странице в админке:
Sitemap: https://example.test/sitemap.xml
Настройки
Раздел «Sitemap» в настройках приложения: исключённые разделы, переопределение приоритетов и частоты, дополнительные адреса (главная страница объявляется там), число адресов в файле, срок годности карты, показ человеческой карты и вариант её оформления из активной темы.
В строках вида blog.post: 0.8, page.page: 0.6 десятичный разделитель — только точка: запятая
разделяет элементы. Нечисловое значение пропускается, 0 — допустимый приоритет.
Параметр robotsFile (по умолчанию @frontend/pub/robots.txt) в настройки не вынесен намеренно:
это параметр развёртывания, а не решение редактора. Задаётся в конфигурации модуля; пустое
значение отключает проверку (например, если robots.txt раздаётся не файлом).
Чего нет намеренно
- Событийной инвалидации («пост опубликован» → пересборка): доменные события у модулей
конкретные, подписка на них вернула бы карте знание о чужих модулях. Свежесть по данным
(
sitemapRevision()) даёт тот же результат без связанности. - Пинга поисковиков: Google выключил
pingв 2024-м, Яндекс его игнорирует. Если понадобится оперативность — это IndexNow, отдельная осознанная фича. .gz-артефактов: nginx жмёт на лету, отдельные файлы только удваивают состояние.- Таблиц и миграций: манифест-файл, консистентно с генерируемой конфигурацией.
Отложено до реальной потребности: расширения image:image / video:video в urlset (когда
галерея станет крупной), флаг «не включать в карту» на уровне сущности (расширение
yii2-cms-meta + миграции в чужих модулях), hreflang (сайт одноязычный).