Делаем 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() в его модель и добавить имя класса модели в этот список.
lordius – google.comЗдравствуйте, большое спасибо за ответ, к сожалению уже решил проблему, похожим методами, создав в компонентах класс, где идет перебор всех моделей адресов и генерация карты сайта и создание настоящей карты сайта. Правда ваш вариант тоже довольно неплох, еще 1 нюанс по поводу вывода карты сайта и ее кеширования, а если процесс трудоемкий и злоумышленник захочет положить сайт, ведь достаточно будет генерировать карту с очисткой кеша.
Дмитрий ЕлисеевА как он сможет сам очищать кэш?
lordius – google.comХм, я имел ввыду кеш браузера, мб я пропусти что в коде или не знаю, но вы же кеш в браузер делаете, или еще мб в бд или где то еще?
З.Ы. мб = может быть.
Дмитрий ЕлисеевКэширование используется на сервере. Обычно включаю файловый, а если сервер поддерживает, то memcache.
TranceSmileТут не имелось ввиду кеш браузера.
TranceSmileОтличный пост.
У Вас небольшая опечатка
Дмитрий ЕлисеевСпасибо. Исправил.
Myres – 3dlip.ruДмитрий спасибо за пост. Это то что я долго искал и не мог сделать сам.
standaloneОшибка: error on line 3 at column 1: Extra content at the end of the document
Дмитрий ЕлисеевВ каком фрагменте кода?
standaloneНе в фрагменте кода, а сразу при формировании xml при переходе на sitemap.xml
Дмитрий ЕлисеевЭто с использованием DSitemap или без? Что в исходном коде страницы? Все ли теги закрыты?
standaloneДа это с DSitemap, вот что сгенерилось http://pastebin.com/y0VY4Dud
Дмитрий ЕлисеевПроверил код листинга DSitemap. Полностью совпадает с рабочим кодом. Проверьте просто без добавления записей:
$dom = new DOMDocument('1.0', 'utf-8'); $urlset = $dom->createElement('urlset'); $urlset->setAttribute('xmlns','http://www.sitemaps.org/schemas/sitemap/0.9'); $dom->appendChild($urlset); return $dom->saveXML();будут ли ошибки?
standaloneСделайте после добавления комментария на сайте $this->refresh(), а то при перезагрузке данные сохраняются в форме
Дмитрий ЕлисеевЭто просто сохранение в Cookie, чтобы повторно имя не набирать.
AlexОтличная статья! Мало того что это то, что я искал (не хотел подключать "толстые" расширения), так еще и подробно описан процесс эволюции кода, обьясняется необходимость этого. Т.е. отличная иллюстрация, даже УРОК того как НЕ делать "в лоб" (обычно, у меня так получается )))) а делать как следует.
Спасибо!
AlexА вот скажите, вот по Вашему мнению - нужна ли карта сайта на фронтенде (HTML)? Либо это уже пережиток прошлого? (я не говорю про случаи запутанных клубков меню\подменю - на таких сайтах просто надо меню логичнее построить). Вопрос даже такой, прагматичный: какова сегодня средняя статистика заходов на ссылки "Карта сайта" (заходы из поисковика, думаю не стоит считать)?
Дмитрий ЕлисеевЕсли на сайте большое запутанное меню или если отсутствует поиск, то можно сделать и HTML-карту. А так она не особо нужна.
AlexВопрос:
До секции "Указание частоты обновлений и приоритета" у меня всё работало нормально.
Но когда с помощью копи-паста вставил контроллер и вьюху из секции "Указание частоты обновлений и приоритета" (оставил только модель "Post" в массиве), повалились ошибки:
Дамп $items содержит правильный ассоциативный массив с ключами models,changefreq и тд.
В дампе $item вообще остутсвуют эти ключи. Поэтому после " foreach ($items as $item):" дамп показывает что в переменной $item['models']=null.
Почему-то после "foreach ($items as $item):" массив развалился ))))
Дмитрий ЕлисеевИсправил array(...) на array(array(...)) в этом примере. Попробуйте ещё раз.
AlexСпасибо, заработало!
AlexАха-хаааа, как я жестко подставился..... ))) ЧАС сижу валидирую sitemap.xml. Во-первых в коде
надо добавить после закрывающей скобки .PHP_EOL; - иначе Гугл-валидатор вам на дверь покажет ничего не обьясняя,
во-вторых (это самое забавное для меня было):
$items = array_merge($items, array(array( 'models' => CActiveRecord::model($class)->published()->findAll(), 'changefreq' => $options[1], 'priority' => $options[0], )));надо было поменять индексы местами (10 раз смотрел на результат вывода и ниразу не заметил баг)
А для валидации карты я бы посоветовал добавить схему
и перед отправкой на гугл проверить ее сначала на http://www.xmlvalidation.com (поставить галку там использовать схему). Ато как мне придется ждать пару часов пока Гугл разрешит повторный пересмотр и отправку файла карты )))
Автору: было бы чудесно отразить изменения в коде и строчку о предварительной валидации, дабы начинающие как я не наступали на мои грабли....
Дмитрий ЕлисеевДобавил PHP_EOL и исправил индексы, а валидатор теперь найдут в этом комментарии. Спасибо за замечания!
Jakeroid – jakeroid.comОчень полезная статья, спасибо. Прям то, что искал.
данил – ves-vash-dom.ruЗдравствуйте. Скажите, а как можно было бы сделать добавление страниц, которые прошли через пагинатор, скажем такого вида http://www.ves-vash-dom.ru/blog/dizayn?page=2 при использовании вашего метода. Пока я их просто склеиваю с тем, что уже сгенерировалось. Может есть другой способ? Спасибо.
Дмитрий ЕлисеевМожно генерировать ссылку прямо через createUrl():
for ($i=1; $i<=$pages_count; $i++) { $sitemap->addUrl(Yii::app()->createUrl('blog/category', array_merge( array('alias'=>$model->alias), $i>1 ? array('page'=>$i) : array() ))); }Или немного переделать метод getUrl():
public function getUrl($page=1) { return Yii::app()->createUrl('blog/category', array_merge( array('alias'=>$this->alias), $page>1 ? array('page'=>$page) : array()) ) }и для модели получать ссылку на нужную страницу:
for ($i=1; $i<=$page_count; $i++) { $sitemap->addUrl($model->getUrl($i)); }
seydametСпасибо огромное за статью!
У вас очепятка в коде :) DSiteap вместо DSitemap который у нас имеется.
Дмитрий ЕлисеевИсправил. Спасибо!
bemulima ДолотовЖестоко намудрили))) Меня вполне устраивает 2-3 вариант, спасибо за статью!
bemulima ДолотовЭто только у меня site.ru/sitemap.xml открывается в html формате или у вас нормально xml формате открывается, когда вводите в адресной строке адрес?
bemulima Долотовай чёрт, забыл вот это header("Content-type: text/xml");
Александр ШиллингСпасибо, сделал у себя на сайте, всё работает как часы.
ВикторВсе четко работает, использую dsitemap
но как быть на сайте с двумя языками, пробовал делать два цикла меняя Yii::app lang но меняется не во всех ссылках гдето 90% русских к 10 английских, похоже надо рыть в гетурл (
alexа для какой версии? 1 или 2
Дмитрий ЕлисеевДля первой.
ЮрийСпасибо, хорошее решение. Предлагаю сделать еще лучше. Файл sitemap имеет ограничение на количество адресов страниц - 50000. Старые успешные проекты запросто могут иметь на много больше. Если добавите (при достижении лимита) возможность создания нескольких файлов (sitemap1 sitemap2 и т.д.) и файла sitemapindex, то получится совсем универсально. Я общий массив резал по 50К, результаты рендерил с выводом в файл, файлы складывал в отдельную директорию (планировщиком); а по запросу генерировал sitemapindex. Может и Вы что-то подобное (а может и лучше) добавите.
demonafiА почему бы не выложить готовый модуль в архиве или github? чтобы можно было полностью посмотреть, а не собирать самостоятельно.
Елена – dicapo.ruА нет ли подобной статьи для Yii2?
Дмитрий ЕлисеевПоищите.
ИльяА как быть с листингом категории? Нужно ли указывать ссылки на страницы типа ...?page=2 и т.д?
Дмитрий ЕлисеевМожно сделать, но для SEO обычно открывают только первую страницу категории.
ИльяУведомление на email приходит от www-data) пофиксите а то не комельфо)
Max ГордиенкоВо круто! Взял ваш DSitemap() и вставил спокойно(чуть исправив) в Yii2, и всё работает как надо :) Спасибо большое.
SergeyА как сделать для yii2. Можно пример кода?
krutik
вйвйвйвтакой гавнокод.... array все сразу понятно об уровне данного человека
Дмитрий Елисеев> array все сразу понятно...
Это статья 2013-го года.