Делаем Sitemap для проекта на Yii
Поразмышлять о вариантах создания карты сайта для проекта на фрэймворке Yii сподвиг этот вопрос на русскоязычном форуме. Наверняка это пригодится для любого более-менее насыщенного страницами проекта. Каждый, несомненно, делает это по своему. Конечно же, можно выбрать любое другое готовое расширение, но для образовательных целей попробуем придумать пару вариантов решения этого вопроса.
Первым делом, прикажем маршрутизатору при запросе файла sitemap.xml
обращаться к контроллеру SitemapController
:
'sitemap.xml'=>'sitemap/index',
Или лучше так:
array('sitemap/index', 'pattern'=>'sitemap.xml', 'urlSuffix'=>''),
Начнём написание нашего контроллера в лучших традициях с худшего варианта:
class SitemapController extends Controller { public function actionIndex() { $urls = array(); // Записи блога $posts = Post::model()->findAll(array( 'condition' => 't.public = 1 AND t.date <= NOW()'; )); foreach ($posts as $post){ $urls[] = $this->createUrl('post/view', array('id'=>$post->id, 'alias'=>$post->alias)); } // Страницы $pages = Page::model()->findAll(array( 'condition' => 't.public = 1'; )); foreach ($posts as $page){ $urls[] = $this->createUrl('page/view', array('alias'=>$page->alias)); } // Новости $news = News::model()->findAll(array( 'condition' => 't.public = 1'; )); foreach ($news as $new){ $urls[] = $this->createUrl('news/view', array('id'=>$new->id)); } // Работы портфолио $works = Work::model()->findAll(array( 'condition' => 't.public = 1'; )); foreach ($works as $work){ $urls[] = $this->createUrl('work/view', array('id'=>$work->id)); } // Товары $products = Product::model()->findAll(array( 'condition' => 't.public = 1 AND t.count > 0'; )); foreach ($products as $product){ $urls[] = $this->createUrl('product/view', array('category'=>$product->category->alias, 'id'=>$product->id)); } // ... $host = Yii::app()->request->hostInfo; echo '<?xml version="1.0" encoding="UTF-8"?>' . PHP_EOL; echo '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">'; foreach ($urls as $url){ echo '<url> <loc>' . $host . $url '</loc> <changefreq>daily</changefreq> <priority>0.5</priority> </url>'; } echo '</urlset>'; Yii::app()->end(); } }
Что плохого в первом варианте кода?
- Повторение относительно похожих блоков кода для каждой сущности (выборка→перебор, выборка→перебор, выборка→перебор...). Было бы удобнее внести их в один цикл или метод, но...
- Индивидуальные различия некоторых участков (условий поиска и генерации ссылок). В каждом блоке условия выборки разные и ссылки генерируются по-своему. Это, собственно, и мешает нам произвести обобщение.
Займёмся небольшим рефакторингом, а именно:
- Перенесём все
condition
изfindAll
внутрь моделей; - Аналогично скроем генерирование адресов;
- Добавим возможность вывода времени обновления записи;
- Вынесем XML код в представление.
Для первого пункта во всех нужных нам моделях создадим именованную группу условий published()
. Для второго же добавим геттер getUrl()
:
class Post extends CActiveRecord { //... public function scopes() { return array( 'published'=>array( 'condition'=>'t.public = 1 AND t.date <= NOW()', ), ); } private $_url; public function getUrl() { if ($this->_url === null) $this->_url = Yii::app()->createUrl('post/view', array('id'=>$this->id)); return $this->_url; } }
Теперь наш контроллер скинул пару десятков лишних строк:
class SitemapController extends Controller { public function actionIndex() { $items = array(); $items = array_merge($items, Page::model()->published()->findAll()); $items = array_merge($items, News::model()->published()->findAll()); $items = array_merge($items, Post::model()->published()->findAll()); $items = array_merge($items, Work::model()->published()->findAll()); $items = array_merge($items, Product::model()->published()->findAll()); $this->renderPartial('index', array( 'host'=>Yii::app()->request->hostInfo, 'items'=>$items, )); } }
<?php echo '<?xml version="1.0" encoding="UTF-8"?>' . PHP_EOL <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"> foreach ($items as $item): <url> <loc> echo $host; echo $item->getUrl(); </loc> <lastmod> echo date(DATE_W3C, $item->update_time); </lastmod> <changefreq>daily</changefreq> <priority>0.5</priority> </url> endforeach; </urlset>
Здесь мы выводим дату последнего обновления в формате W3C Datetime, используя поле
update_time
модели формата TIMESTAMP:echo date(DATE_W3C, $item->update_time);
Если же у вас в таблице время хранится в формате DATETIME, то сначала его необходимо преобразовать функцией
strtotime()
:echo date(DATE_W3C, strtotime($item->update_time));
Но и это не предел. Если у всех моделей есть модификатор published()
, то можно уменьшить число строк сборщика массива моделей до трёх:
class SitemapController extends Controller { public function actionIndex() { $items = array(); foreach (array('Post', 'News', 'Page', 'Work', 'Product') as $class) $items = array_merge($items, CActiveRecord::model($class)->published()->findAll()); $this->renderPartial('index', array( 'host'=>Yii::app()->request->hostInfo, 'items'=>$items, )); } }
<?php echo '<?xml version="1.0" encoding="UTF-8"?>' . PHP_EOL <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"> foreach ($items as $item): <url> <loc> echo $host; echo $item->getUrl(); </loc> <lastmod> echo date(DATE_W3C, $item->update_time); </lastmod> <changefreq>daily</changefreq> <priority>0.5</priority> </url> endforeach; </urlset>
Вот теперь можно похвастаться перед друзьями истинно «тонким» контроллером.
Указание частоты обновлений и приоритета
Порядочным поисковым роботам нужно помогать. Мы добавили поддержку параметра lastmod
. Теперь добавим поля changefreq
и priority
. Для этого немного модифицируем последний пример:
class SitemapController extends Controller { const ALWAYS = 'always'; const HOURLY = 'hourly'; const DAILY = 'daily'; const WEEKLY = 'weekly'; const MONTHLY = 'monthly'; const YEARLY = 'yearly'; const NEVER = 'never'; public function actionIndex() { $classes = array( 'Post' => array(self::DAILY, 0.8), 'News' => array(self::DAILY, 0.5), 'Page' => array(self::WEEKLY, 0.2), 'Work' => array(self::WEEKLY, 0.5), 'Product' => array(self::DAILY, 0.5), ); $items = array(); foreach ($classes as $class=>$options){ $items = array_merge($items, array(array( 'models' => CActiveRecord::model($class)->published()->findAll(), 'changefreq' => $options[0], 'priority' => $options[1], ))); } $this->renderPartial('index', array( 'items'=>$items, 'host'=>Yii::app()->request->hostInfo, )); } }
<?php echo '<?xml version="1.0" encoding="UTF-8"?>' . PHP_EOL <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"> foreach ($items as $item): foreach ($item['models'] as $model): <url> <loc> echo $host; echo $model->getUrl(); </loc> <lastmod> echo date(DATE_W3C, $model->update_time); </lastmod> <changefreq> echo $item['changefreq']; </changefreq> <priority> echo $item['priority']; </priority> </url> endforeach; endforeach; </urlset>
Мы расширили массив классов моделей дополнительными параметрами и разным группам указали различные приоритеты и рекомендательные частоты индексирования роботом.
Вынесение логики из контроллера
Код контроллера вполне можно оставить в таком состоянии. Но если кому-то не нравится нахождение всего функционала в контроллере и генерирование XML вручную, то можно пойти дальше.
Вынесем все константы и всю логику генерации карты сайта в отдельный класс:
<?php /** * @author ElisDN <mail@elisdn.ru> * @link https://elisdn.ru */ class DSitemap { const ALWAYS = 'always'; const HOURLY = 'hourly'; const DAILY = 'daily'; const WEEKLY = 'weekly'; const MONTHLY = 'monthly'; const YEARLY = 'yearly'; const NEVER = 'never'; protected $items = array(); /** * @param $url * @param string $changeFreq * @param float $priority * @param int $lastmod */ public function addUrl($url, $changeFreq=self::DAILY, $priority=0.5, $lastMod=0) { $host = Yii::app()->request->hostInfo; $item = array( 'loc' => $host . $url, 'changefreq' => $changeFreq, 'priority' => $priority ); if ($lastMod) $item['lastmod'] = $this->dateToW3C($lastMod); $this->items[] = $item; } /** * @param CActiveRecord[] $models * @param string $changeFreq * @param float $priority */ public function addModels($models, $changeFreq=self::DAILY, $priority=0.5) { $host = Yii::app()->request->hostInfo; foreach ($models as $model) { $item = array( 'loc' => $host . $model->getUrl(), 'changefreq' => $changeFreq, 'priority' => $priority ); if ($model->hasAttribute('update_time')) $item['lastmod'] = $this->dateToW3C($model->update_time); $this->items[] = $item; } } /** * @return string XML code */ public function render() { $dom = new DOMDocument('1.0', 'utf-8'); $urlset = $dom->createElement('urlset'); $urlset->setAttribute('xmlns','http://www.sitemaps.org/schemas/sitemap/0.9'); foreach($this->items as $item) { $url = $dom->createElement('url'); foreach ($item as $key=>$value) { $elem = $dom->createElement($key); $elem->appendChild($dom->createTextNode($value)); $url->appendChild($elem); } $urlset->appendChild($url); } $dom->appendChild($urlset); return $dom->saveXML(); } protected function dateToW3C($date) { if (is_int($date)) return date(DATE_W3C, $date); else return date(DATE_W3C, strtotime($date)); } }
Заметим, что в классе мы полностью автоматизировали работу с атрибутом
update_time
модели. Если это поле у модели существует, то оно автоматически переконвертируется в нужный формат и выведется в опцииlastmod
.
Теперь в контроллере создадим объект, добавим в него наши модели и выведем сгенерированный результат на экран:
class SitemapController extends Controller { public function actionIndex() { $sitemap = new DSitemap(); $sitemap->addModels(Post::model()->published()->findAll(), DSitemap::DAILY, 0.8); $sitemap->addModels(News::model()->published()->findAll(), DSitemap::DAILY, 0.5); $sitemap->addModels(Page::model()->published()->findAll(), DSitemap::WEEKLY, 0.2); $sitemap->addModels(Work::model()->published()->findAll(), DSitemap::WEEKLY, 0.5); $sitemap->addModels(Product::model()->published()->findAll(), DSitemap::DAILY, 0.5); header("Content-type: text/xml"); echo $sitemap->render(); Yii::app()->end(); } }
Или расширим наш предыдущий вариант с массивом:
class SitemapController extends Controller { public function actionIndex() { $classes = array( 'Post' => array(DSitemap::DAILY, 0.8), 'News' => array(DSitemap::DAILY, 0.5), 'Page' => array(DSitemap::WEEKLY, 0.2), 'Work' => array(DSitemap::WEEKLY, 0.5), 'Product' => array(DSitemap::DAILY, 0.5), ); $sitemap = new DSitemap(); foreach ($classes as $class=>$options) $sitemap->addModels(CActiveRecord::model($class)->published()->findAll(), $options[0], $options[1]); header("Content-type: text/xml"); echo $sitemap->render(); Yii::app()->end(); } }
Здесь мы также создаём объект $sitemap
и в цикле передаём ему наши модели.
Оптимизация производительности
В наших немного примитивных примерах каждый раз производится выборка всех моделей методом findAll()
. Если у нас, предположим, тысячи товаров в магазине, то такая выборка может не сработать ввиду ограничения доступной оперативной памяти.
В таких случаях необходимо либо ограничивать число выбираемых элементов с помощью параметра LIMIT
(например, вместо тысячи моделей сразу выбирать десять раз по сто элементов), либо использовать менее ресурсоёмкие варианты перебора. Это может быть DAO или CDataProviderIterator.
Также в этом случае не стоит брать DomDocument
, а лучше передавать итератор в упоминавшееся выше представление через renderPartial()
и генерировать ссылки простой конкатенацией строк вместо createUrl
.
Потом нужно либо кэшировать полученный XML на несколько часов (чтобы не запускать этот процесс при каждом запросе), либо перенести код генератора в консольную команду, которая сохраняет вывод в настоящий файл sitemap.xml
и запускать эту команду планировщиком.
Это несколько отдельных тем, но мы их здесь рассматривать не будем. Но, чтобы не генерировать карту сайта при каждом запросе, добавим кэширование результата на 6 часов:
class SitemapController extends Controller { public function actionIndex() { if (!$xml = Yii::app()->cache->get('sitemap')) { $classes = array( 'Post' => array(DSitemap::DAILY, 0.8), 'News' => array(DSitemap::DAILY, 0.5), 'Page' => array(DSitemap::WEEKLY, 0.2), 'Work' => array(DSitemap::WEEKLY, 0.5), 'Product' => array(DSitemap::DAILY, 0.5), ); $sitemap = new DSitemap(); $sitemap->addUrl('/contacts', DSitemap::WEEKLY); foreach ($classes as $class=>$options) $sitemap->addModels(CActiveRecord::model($class)->published()->findAll(), $options[0], $options[1]); $xml = $sitemap->render(); Yii::app()->cache->set('sitemap', $xml, 3600*6); } header("Content-type: text/xml"); echo $xml; Yii::app()->end(); } }
Теперь для подключения нового модуля на сайте нужно добавить группу условий published
и геттер getUrl()
в его модель и добавить имя класса модели в этот список.
Здравствуйте, большое спасибо за ответ, к сожалению уже решил проблему, похожим методами, создав в компонентах класс, где идет перебор всех моделей адресов и генерация карты сайта и создание настоящей карты сайта. Правда ваш вариант тоже довольно неплох, еще 1 нюанс по поводу вывода карты сайта и ее кеширования, а если процесс трудоемкий и злоумышленник захочет положить сайт, ведь достаточно будет генерировать карту с очисткой кеша.
А как он сможет сам очищать кэш?
Хм, я имел ввыду кеш браузера, мб я пропусти что в коде или не знаю, но вы же кеш в браузер делаете, или еще мб в бд или где то еще?
З.Ы. мб = может быть.
Кэширование используется на сервере. Обычно включаю файловый, а если сервер поддерживает, то memcache.
Тут не имелось ввиду кеш браузера.
Отличный пост.
У Вас небольшая опечатка
Спасибо. Исправил.
Дмитрий спасибо за пост. Это то что я долго искал и не мог сделать сам.
Ошибка: error on line 3 at column 1: Extra content at the end of the document
В каком фрагменте кода?
Не в фрагменте кода, а сразу при формировании xml при переходе на sitemap.xml
Это с использованием DSitemap или без? Что в исходном коде страницы? Все ли теги закрыты?
Да это с DSitemap, вот что сгенерилось http://pastebin.com/y0VY4Dud
Проверил код листинга DSitemap. Полностью совпадает с рабочим кодом. Проверьте просто без добавления записей:
будут ли ошибки?
Сделайте после добавления комментария на сайте $this->refresh(), а то при перезагрузке данные сохраняются в форме
Это просто сохранение в Cookie, чтобы повторно имя не набирать.
Отличная статья! Мало того что это то, что я искал (не хотел подключать "толстые" расширения), так еще и подробно описан процесс эволюции кода, обьясняется необходимость этого. Т.е. отличная иллюстрация, даже УРОК того как НЕ делать "в лоб" (обычно, у меня так получается )))) а делать как следует.
Спасибо!
А вот скажите, вот по Вашему мнению - нужна ли карта сайта на фронтенде (HTML)? Либо это уже пережиток прошлого? (я не говорю про случаи запутанных клубков меню\подменю - на таких сайтах просто надо меню логичнее построить). Вопрос даже такой, прагматичный: какова сегодня средняя статистика заходов на ссылки "Карта сайта" (заходы из поисковика, думаю не стоит считать)?
Если на сайте большое запутанное меню или если отсутствует поиск, то можно сделать и HTML-карту. А так она не особо нужна.
Вопрос:
До секции "Указание частоты обновлений и приоритета" у меня всё работало нормально.
Но когда с помощью копи-паста вставил контроллер и вьюху из секции "Указание частоты обновлений и приоритета" (оставил только модель "Post" в массиве), повалились ошибки:
Дамп $items содержит правильный ассоциативный массив с ключами models,changefreq и тд.
В дампе $item вообще остутсвуют эти ключи. Поэтому после " foreach ($items as $item):" дамп показывает что в переменной $item['models']=null.
Почему-то после "foreach ($items as $item):" массив развалился ))))
Исправил array(...) на array(array(...)) в этом примере. Попробуйте ещё раз.
Спасибо, заработало!
Аха-хаааа, как я жестко подставился..... ))) ЧАС сижу валидирую sitemap.xml. Во-первых в коде
надо добавить после закрывающей скобки .PHP_EOL; - иначе Гугл-валидатор вам на дверь покажет ничего не обьясняя,
во-вторых (это самое забавное для меня было):
надо было поменять индексы местами (10 раз смотрел на результат вывода и ниразу не заметил баг)
А для валидации карты я бы посоветовал добавить схему
и перед отправкой на гугл проверить ее сначала на http://www.xmlvalidation.com (поставить галку там использовать схему). Ато как мне придется ждать пару часов пока Гугл разрешит повторный пересмотр и отправку файла карты )))
Автору: было бы чудесно отразить изменения в коде и строчку о предварительной валидации, дабы начинающие как я не наступали на мои грабли....
Добавил PHP_EOL и исправил индексы, а валидатор теперь найдут в этом комментарии. Спасибо за замечания!
Очень полезная статья, спасибо. Прям то, что искал.
Здравствуйте. Скажите, а как можно было бы сделать добавление страниц, которые прошли через пагинатор, скажем такого вида http://www.ves-vash-dom.ru/blog/dizayn?page=2 при использовании вашего метода. Пока я их просто склеиваю с тем, что уже сгенерировалось. Может есть другой способ? Спасибо.
Можно генерировать ссылку прямо через createUrl():
Или немного переделать метод getUrl():
и для модели получать ссылку на нужную страницу:
Спасибо огромное за статью!
У вас очепятка в коде :) DSiteap вместо DSitemap который у нас имеется.
Исправил. Спасибо!
Жестоко намудрили))) Меня вполне устраивает 2-3 вариант, спасибо за статью!
Это только у меня site.ru/sitemap.xml открывается в html формате или у вас нормально xml формате открывается, когда вводите в адресной строке адрес?
ай чёрт, забыл вот это header("Content-type: text/xml");
Спасибо, сделал у себя на сайте, всё работает как часы.
Все четко работает, использую dsitemap
но как быть на сайте с двумя языками, пробовал делать два цикла меняя Yii::app lang но меняется не во всех ссылках гдето 90% русских к 10 английских, похоже надо рыть в гетурл (
а для какой версии? 1 или 2
Для первой.
Спасибо, хорошее решение. Предлагаю сделать еще лучше. Файл sitemap имеет ограничение на количество адресов страниц - 50000. Старые успешные проекты запросто могут иметь на много больше. Если добавите (при достижении лимита) возможность создания нескольких файлов (sitemap1 sitemap2 и т.д.) и файла sitemapindex, то получится совсем универсально. Я общий массив резал по 50К, результаты рендерил с выводом в файл, файлы складывал в отдельную директорию (планировщиком); а по запросу генерировал sitemapindex. Может и Вы что-то подобное (а может и лучше) добавите.
А почему бы не выложить готовый модуль в архиве или github? чтобы можно было полностью посмотреть, а не собирать самостоятельно.
А нет ли подобной статьи для Yii2?
Поищите.
А как быть с листингом категории? Нужно ли указывать ссылки на страницы типа ...?page=2 и т.д?
Можно сделать, но для SEO обычно открывают только первую страницу категории.
Уведомление на email приходит от www-data) пофиксите а то не комельфо)
Во круто! Взял ваш DSitemap() и вставил спокойно(чуть исправив) в Yii2, и всё работает как надо :) Спасибо большое.
А как сделать для yii2. Можно пример кода?
такой гавнокод.... array все сразу понятно об уровне данного человека
> array все сразу понятно...
Это статья 2013-го года.