- PVSM.RU - https://www.pvsm.ru -
Доброго времени суток, читатели!
Недавно задался целью сделать поиск на своем сайте, написанном на Kohana Framework. Решил использовать именно морфологический поиск, т.к. считаю его более правильным (относительно полнотекстового поиска с применением LIKE). Поиски готовых модулей для Kohana с требующимся функционалом не увенчались успехом, но я нашел отличную библиотеку: phpMorphy [1], которая замечательно подошла для решения моих задач.
На сайте мы имеем следующую структуру:
Как видно из прикрепленной схемы, на сайте присутствует контент 2-х типов:
Мы собираемся индексировать весь этот контент, причем в связи с тем, что комментарии могут появляться на протяжении всего времени существования контента — переиндексировать контент нужно на постоянной основе. С точки зрения логики, индексация контента выглядит следующим образом: Поочередно получаем весь контент, осуществляем поиск относящихся к контенту дополнительных материалов (комментарии, ингредиенты, шаги приготовления). Далее проводим такие операции:
После того, как весь контент проиндексирован, остается самое простое — поиск по созданному поисковому индексу. Для этого мы действуем аналогичным образом:
После того, как стала понятна логическая составляющая вопроса — можно приступить к разбору кода.
Для начала идем на страничку проекта на Sourceforge [2] и скачиваем актуальную версию библиотеки, а также базы словарей (т.к. Kohana работает с utf-8 — скачиваем словари для этой кодировки).
Разработчики рекомендуют размещать файлы библиотеки таким образом, чтобы они не были доступны напрямую из web. Я не уточнял по каким причинам, поэтому предлагаю не злоупотреблять рекомендацией и залить файлы либо выше директории /www, либо (в случае, если будете заливать в какую либо директорию внутри /www) запрещать прямое обращение к папке из web. Это можно сделать, поместив в папку файл .htaccess:
Options -Indexes
<Files ~ ".(php|php3|php4|php5|pl|cgi|sh|bash)$">
Deny from all
</Files>
Для использования функционала библиотеки нужно подключить необходимые файлы и создать экземпляр класса, с которым будут предприниматься дальнейшие действия:
require_once('{путь до директории с библиотекой}/src/common.php');
$dir = '{путь до директории, в которую мы разархивировали словари}/dicts';
$lang = 'ru_RU';
$opts = array(
'storage' => PHPMORPHY_STORAGE_FILE,
);
try
{
$morphy = new phpMorphy($dir, $lang, $opts);
}
catch(phpMorphy_Exception $e)
{
die('Error occured while creating phpMorphy instance: ' . $e->getMessage());
}
В предлагаемом мной решении используется 2 контроллера:
Кроме этого, для удобства (я лично использую ORM) нужно создать модель:
class Model_Searchindex extends ORM {
protected $_table_name = 'searchindex';
}
Ну и, соответственно, таблицу 'searchindex', состоящую из полей:
Таблица должна иметь тип MyISAM
Поговорим подробнее о каждом из контроллеров.
Данный контроллер в моем случае используется как обработчик, к которому я обращаюсь асинхронными запросами из панели управления сайтом (естественно, контроллер доступен только пользователю с правами администратора).
Настраиваем в роутах возможность получения дополнительного параметра (т.к. операция тяжелая и хотелось бы разбить её на порции):
Route::set('index', 'updateindex(/<offset>)')
->defaults(array(
'directory' => 'admin',
'controller' => 'updateindex',
'action' => 'index',
));
Ну, с роутами, я думаю, все поняли, что имеется в виду. Далее, в самом котроллере, в action_index() принимаем параметр offset, создаем экземпляр класса phpMorphy, и производим все операции, описанные в логической схеме:
$offset = $this->request->param('offset');
// при переиндексации очищаем старую базу индексов
if ($offset == 1)
{
$index = DB::query(Database::DELETE, 'DELETE FROM `searchindex`');
$index->execute();
}
$data = array();
// Тут получаем список постов
$posts = ORM::factory('post')->where('delete', '=', 0)->offset(100*$offset)->limit(100)->find_all();
foreach ($posts as $post)
{
$words = array();
// Очищаем от html, заменяем Ё на Е и приводим к верхнему регистру
$title = mb_strtoupper(str_ireplace("ё", "е", strip_tags($post->title)), "UTF-8");
$comments = ORM::factory('comment')->where('post_id', '=', $post->id)->order_by('id', 'ASC')->find_all(); // Получаем комментарии, относящиеся к посту
$text = $post->text;
if ($post->type == 1)
{
// Тут проводим тоже самое, но с ингредиентами и шагами приготовления. Думаю, хабрасообществу это не так интересно...
}
foreach ($comments as $comment)
{
// Для сокращения объема примем, что текст поста и комментариев имеет одинаковый вес
$text = $text.' '.$comment->text;
}
$text = mb_strtoupper (str_ireplace("ё", "е", strip_tags($text)), "UTF-8");
preg_match_all ('/([a-zа-яё]+)/ui', $title, $word_title); // Разбиваем текст на слова
preg_match_all ('/([a-zа-яё]+)/ui', $text, $word_text);
// Получаем нормальную форму слова, например помидоров => помидор
$start_form_title = $morphy->lemmatize($word_title[1]);
$start_form_text = $morphy->lemmatize($word_text[1]);
foreach ($start_form_title as $k=>$w)
{
if (!$w)
{
// Если не получилось определить начальную форму слова, используем исходное слово
$w[0] = $k;
}
if (mb_strlen($w[0], "UTF-8") > 2) // Проверяем длину слова, не индексируем короткие слова
{
if (! isset ( $words[$w[0]]))$words[$w[0]] = 0;
$words[$w[0]]+= 3; // Устанавливаем вес для слова
}
}
foreach ($start_form_text as $k=>$w)
{
// Аналогично для основного текста
}
// Тут перебираем массив значений и заносим их в базу
foreach ($words as $word=>$weight)
{
$data['post_id'] = $post->id;
$data['word'] = $word;
$data['weight'] = $weight;
$addindex = ORM::factory('searchindex');
$addindex->values($data);
try
{
$addindex->save();
}
catch (ORM_Validation_Exception $e)
{
$errors = $e->errors('validation');
}
}
}
/* Тут формируем ответ в виде jquery, чтобы в панели управления вывести динамический блок, и показывать прогрессбар выполнения операции */
$pcount = ORM::factory('post')->where('delete', '=', 0)->count_all();
if (($pcount - (100*$offset)) > 0)
{
$complateu = ($offset) * 100;
$percent = ($complateu / $pcount) * 100;
$percent = round($percent, 0);
$json = array('status'=>'next', 'nextid'=>1+$offset, 'percent'=>$percent);
$this->response->body(json_encode($json));
}
else
{
$json = array('status'=>'finish', 'percent'=>100);
$this->response->body(json_encode($json));
}
Думаю, что не стоит приводить код реализации панели управления (учитывая, что и сейчас объем статьи не маленький). Там всё достаточно банально — кнопка, и jquery обработчик, обращающийся к вышеописанному контроллеру и соответствующим образом обрабатывающий получаемый ответ.
Для работы данного контроллера аналогичным образом создаем роут. Контроллер принимает поисковую фразу, введенную пользователем. Фраза передается методом GET. Так выглядит контроллер:
public function action_search()
{
$data = null;
$request = null;
$errors = null;
if (!empty($_GET['text'])) // Получаем поисковый запрос
{
// Очищаем от html-тегов и прочего
$search = $this->_clear_var($_GET['text']);
$request = $search;
}
/* Создаем экземпляр phpMorphy */
if (!empty($search))
{
// Обрабатываем данные как и в прошлом контроллере
if (mb_strlen($search, "UTF-8") > 2)
{
preg_match_all('/([a-zа-яё]+)/ui', mb_strtoupper($search, "UTF-8"), $search_words);
$words = $morphy->lemmatize($search_words[1]);
$s_words = array();
$pre_result = array();
foreach ($words as $k => $w)
{
if (!$w)$w[0] = $k;
if (mb_strlen($w[0], "UTF-8") > 2)
{
$s_words[] = $w[0];
}
}
if (!count($s_words))
{
// Обрабатываем ошибку (нет ни одного слова длиннее 2 символов)
}
else
{
foreach($s_words as $s_word)
{
$search_index = ORM::factory('searchindex')->where('word', '=', $s_word)->find_all();
foreach ($search_index as $si)
{
if (!empty($pre_result[$si->post_id]))
{
$pre_result[$si->post_id] = (int) $si->weight + $pre_result[$si->post_id];
}
else
{
$pre_result[$si->post_id] = (int) $si->weight;
}
}
}
arsort($pre_result); // Сортируем массив по весу результатов
foreach ($pre_result as $id => $weight)
{
// Тут, соответственно, получаем данные о результатах и помещаем в массив
$data[] = $result;
}
}
}
else
{
// Обрабатываем ошибку - введен слишком короткий запрос
}
}
else
{
// Обрабатываем ошибку - пустой поисковый запрос
}
$this->template->content = View::factory('content/v_search')
->bind('data', $data)
->bind('errors', $errors)
->bind('request', $request)
}
Ну, вывод информации, думаю, описывать смысла большого нет — тут всё как обычно. Листинги кода старался как можно больше сократить, чтобы не захламлять статью совсем простыми и банальными вещами (такими как получение информации из БД или обработка ошибок, все и так знают, как это делается...).
Надеюсь, моя статья будет полезна Хабрасообществу. Пример работы данной реализации поиска можете посмотреть тут [3].
Автор: Podpole
Источник [4]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/php-2/24733
Ссылки в тексте:
[1] phpMorphy: http://sourceforge.net/projects/phpmorphy/
[2] Sourceforge: http://phpmorphy.sourceforge.net/dokuwiki/download
[3] тут: http://cook-room.com/
[4] Источник: http://habrahabr.ru/post/165715/
Нажмите здесь для печати.