В ZendFramework есть довольно мощный модуль поиска Zend_Search, который позволяет индексировать контент и производить по нему поиск с приличной скоростью и без участия БД или каких-либо нестандартных PHP расширений. Дабы не изобретать колесо, имеет смысл использовать этот модуль совместно с Limb3.
В примере используются Limb3-2007.1 и ZendFramework-0.8
Ниже приведено описание возможного использование модуля поиска Zend_Search совместно с пакетоми WEB_SPIDER и SEARCH в некотором абстрактном проекте.
Что в итоге должно получиться:
Прежде чем начнем, стоит заметить, как правильно настроить include_path для Limb3 и ZendFramework. Где-то в setup.php своего проекта стоит поместить подобную строчку:
<?php set_include_path(dirname(__FILE__) . '/' . PATH_SEPARATOR . dirname(__FILE__) . '/lib/' . PATH_SEPARATOR . get_include_path()); ?>
Это подразумевает, что и Limb3 и ZendFramework расположены в директории lib проекта. Однако ставить весь ZF смысла нет - потребуется только директория Search и все ее содержимое и файл Exception.php в корне директории. Выглядит это примерно так:
`-lib `-limb/... `-Zend `-Search/.. `-Exception.php
С установками разобрались, перейдем сразу к шелл скрипту.
cli/indexer.php
<?php //аргументом для скрипта является url сайта, например http://mysite.com if(!isset($argv[1])) die("index starting uri not specified!\n"); set_time_limit(0); ini_set('memory_limit', '512M'); //некие проектные установки, например include_path и проч. require_once(dirname(__FILE__) . '/../setup.php'); require_once('limb/net/src/lmbUri.class.php'); require_once('limb/web_spider/src/lmbWebSpider.class.php'); require_once('limb/web_spider/src/lmbUriFilter.class.php'); require_once('limb/web_spider/src/lmbContentTypeFilter.class.php'); require_once('limb/web_spider/src/lmbSearchIndexingObserver.class.php'); require_once('limb/search/src/indexer/lmbSearchTextNormalizer.class.php'); require_once('limb/web_spider/src/lmbUriNormalizer.class.php'); require_once('src/search/ZendSearchIndexer.class.php'); $uri = new lmbUri($argv[1]); $indexer = new ZendSearchIndexer(new lmbSearchTextNormalizer()); $indexer->useNOINDEX(); $observer = new lmbSearchIndexingObserver($indexer); $content_type_filter = new lmbContentTypeFilter(); $content_type_filter->allowContentType('text/html'); $uri_filter = new lmbUriFilter(); $uri_filter->allowHost($uri->getHost()); $uri_filter->allowProtocol('http'); $uri_filter->allowPathRegex('~.*~'); $normalizer = new lmbUriNormalizer(); $normalizer->stripQueryItem('PHPSESSID'); $spider = new lmbWebSpider(); $spider->setContentTypeFilter($content_type_filter); $spider->setUriFilter($uri_filter); $spider->setUriNormalizer($normalizer); $spider->registerObserver($observer); $spider->crawl($uri); ?>
В приведенном выше скрипте происходит много чего интересного, однако, по-большому счету, мы просто объединяем несколько компонентов в единое целое и конфигурируем их. Более подробная информация о пакете WEB_SPIDER и SEARCH появится в соответсвующих разделах, здесь лишь приведем базовую информацию.
Использование из командной строки этого шелл скрипта крайне простое:
$ php indexer.php http://mysite.com
При выполнении скрипт выводит информацию о том, какие именно страницы обходятся в данный момент. Если все нормально, должно появится нечто похожее на это:
1)started indexing http://mysite.com...done 3)started indexing http://mysite.com/news...done 4)started indexing http://mysite.com/about...done 5)started indexing http://mysite.com/en...done 6)started indexing http://mysite.com/en/news...done ...
Приведем код индексатора:
src/search/ZendSearchIndexer.class.php
require_once('Zend/Search/Lucene.php'); require_once('src/search/SearchTextTools.class.php'); class ZendSearchIndexer { protected $normalizer = null; protected $left_bound = '<!-- no index start -->'; protected $right_bound = '<!-- no index end -->'; protected $use_noindex = false; protected $index; function __construct($normalizer) { $this->normalizer = $normalizer; } function useNOINDEX($status = true) { $this->use_noindex = $status; } function index($uri, $content) { //получаем содержимое <title>...</title> $title = $this->_extractTitle($content); //вырезаем контент который необходимо необходимо индексировать, //если стоит опция use_noindex $content = $this->_getIndexedContent($content); //нормализуем контент нормализатором(такое вот масло масляное): //вырезаем теги $content = $this->normalizer->process($content); //индексируем контент $doc = new Zend_Search_Lucene_Document(); $doc->addField(Zend_Search_Lucene_Field::Text('uri', $uri->toString())); //производим транслитерацию полей из русскоязычного UTF8 в us-ascii $field = Zend_Search_Lucene_Field::Text('title', SearchTextTools :: sanitize($title)); //увеличиваем релевантность заголовков $field->boost = 1.5; $doc->addField($field); $doc->addField(Zend_Search_Lucene_Field::Text('content', SearchTextTools :: sanitize($content))); //сохраняем оригинальный заголовок без изменений, в поиске он не участвует! $doc->addField(Zend_Search_Lucene_Field::Binary('title_orig', $title)); $index = $this->_getIndex(); //индексер от ZendFramework выдает надоедливый notice во время использования iconv, //избавляемся от него, возможно, стоит просто на время индексации ставить error_level //без notice @$index->addDocument($doc); } function _getIndex() { if(!$this->index) $this->index = Zend_Search_Lucene::create(LIMB_VAR_DIR . '/search_index'); return $this->index; } function _getIndexedContent($content) { if(!$this->use_noindex) return $content; $regex = '~' . preg_quote($this->left_bound) . '(.*?)' . preg_quote($this->right_bound) . '~s'; return preg_replace($regex, ' ', $content); } function _extractTitle(&$content) { $regex = '~<title>([^<]*)</title>~'; if(preg_match($regex, $content, $matches)) return $matches[1]; else return ''; } } ?>
Центральным методом индексера является index($uri, $content), в который приходит объект lmbUri и ненормализованный контент(со всеми тегами). В комментариях написано, что именно происходит, остановимся лишь на непонятном аттрибуте $use_noindex и методе SearchTextTools :: sanitize(..):
Транслитерация и очистка текста от не ascii символов производится классом SearchTextTools:
src/search/SearchTextTools.class.php
<?php class SearchTextTools { function translit($input) { $arrRus = array('а', 'б', 'в', 'г', 'д', 'е', 'ё', 'ж', 'з', 'и', 'й', 'к', 'л', 'м', 'н', 'о', 'п', 'р', 'с', 'т', 'у', 'ф', 'х', 'ц', 'ч', 'ш', 'щ', 'ь', 'ы', 'ъ', 'э', 'ю', 'я', 'А', 'Б', 'В', 'Г', 'Д', 'Е', 'Ё', 'Ж', 'З', 'И', 'Й', 'К', 'Л', 'М', 'Н', 'О', 'П', 'Р', 'С', 'Т', 'У', 'Ф', 'Х', 'Ц', 'Ч', 'Ш', 'Щ', 'Ь', 'Ы', 'Ъ', 'Э', 'Ю', 'Я'); $arrEng = array('a', 'b', 'v', 'g', 'd', 'e', 'jo', 'zh', 'z', 'i', 'y', 'k', 'l', 'm', 'n', 'o', 'p', 'r', 's', 't', 'u', 'f', 'kh', 'c', 'ch', 'sh', 'sch', '', 'y', '', 'e', 'ju', 'ja', 'A', 'B', 'V', 'G', 'D', 'E', 'JO', 'ZH', 'Z', 'I', 'Y', 'K', 'L', 'M', 'N', 'O', 'P', 'R', 'S', 'T', 'U', 'F', 'KH', 'C', 'CH', 'SH', 'SCH', '', 'Y', '', 'E', 'JU', 'JA'); return str_replace($arrRus, $arrEng, $input); } function sanitize($content) { $content = strtolower(self :: translit($content)); $len = strlen($content); $res = ""; for($i=0; $i < $len; ++$i) { $ord = ord($content{$i}); if($ord >= 0x80) continue; $res .= $content{$i}; } return $res; } } ?>
Здесь должно быть все понятно, поэтому перейдем сразу к контроллеру
src/controller/SearchController.class.php
<?php class SearchController extends lmbController { function doSearch() { require_once('src/search/SearchTextTools.class.php'); require_once('Zend/Search/Lucene.php'); $query = SearchTextTools :: sanitize(implode(' ', $this->_getQueryWords())); $index = Zend_Search_Lucene :: open(LIMB_VAR_DIR . '/search_index'); try { $hits = $index->find($query); } catch(Zend_Exception $e) { $hits = array(); } $result = array(); foreach($hits as $hit) { $result[] = array('id' => $hit->id, 'score' => $hit->score, 'title' => $hit->title_orig, 'uri' => $hit->uri); } $this->view->set('search_result', $result); } protected function _getQueryWords() { $query = $this->request->get('query_string'); return explode(' ', htmlspecialchars($query)); } } ?>
В контроллере мы получаем строку запроса и обрабатываем ее, т.к индекс у нас хранится в транслите, следовательно и запрос поиска тоже необходимо делать в транслите, что и делается при помощи SearchTextTools :: sanitize(..). После этого собственно делаем запрос.
Помните мы хранили бинарную неиндексируемую строку заголовка без изменений? Именно ее мы и возвращаем во view вместо транслителируемой.
Кроме проблем с индексацией не ascii строк, есть еще одна неприятная особенность - невозможность частичного поиска слова, хотя, возможно мы плохо смотрели документацию Zend_Search.
Обсуждение