×

Laravel – как сделать поиск по сайту.

Laravel – как сделать поиск по сайту.

Поиск по сайту является обязательным функционалом любого современного сайта, особенно интернет-магазина с большим количеством товаров, так как значительно упрощает их поиск и делает сайт удобным для посетителей.

В данной статье мы, шаг за шагом сделаем поиск по сайту, точнее по каталогу, а также, добавим функционал для улучшения релевантности поиска.

Предлагаю опустить процесс установки фреймворка Laravel, настройки и подключения базы данных. Уверен, что для Вас не проблема, а при необходимости Вы можете легко это сделать самостоятельно, так как это подробно описано в самой документации к фреймворку и многочисленных гайдах в сети.

И так представим, что в процессе разработки интернет-магазине нам нужно сделать поиск товаров каталога по их названиям. Фреймворк уже установлен, база данных подключена, необходимые модели и миграции созданы, в том числе и таблица базы данных товаров: products и модель Product.

В таблица товаров products – содержит ряд полей с информацией о товаре: цене, характеристиках товара, описании и т.д., в том числе поле: name, строковое значение которого содержит информацию о названии товара. Именно по нему мы будем выбирать товары каталога.

И так приступим, сам процесс создания поиска сделаем в несколько этапов. Создадим:

  1. Форму поиска по каталогу
  2. Маршрут, к методу контроллера обработчику запроса
  3. Метод контроллера – обработчик поискового запроса
  4. View – шаблон для вывода результатов поиска.

Форма поиска по каталогу

Форма поиска в моем случае находится в шапке, в блоке HEADER сайта, и элемент View – шаблона отвечающего за вывод формы поиска выглядит так:

<form action="{{route('search')}}" method="GET" class="form-wrap">
	<input id="title-search-input" type="text" name="s" value="" class="search-form @error('s') is-invalid @enderror" required placeholder="Поиск">
	<button type="submit" class="search-btn"><span class="icon-search"></span></button>
</form>

Обратите внимание, данная форма отправляется методом GET, содержит action – с маршрутом, который мы сейчас создадим. Так же, вы можете заметить код обработки сообщения о наличии валидационных ошибок: @error('s') is-invalid @enderror.

Маршрут, к методу контроллера обработчику запроса

Создаем маршрут, который отправляет наш запрос на сервер методом GET:

//  routes/web.php

Route::get('/search', [App\Http\Controllers\Front\CatalogController::class, 'search'])->name('search');

Как видите, маршрут обращается к методу search контроллера CatalogController, я не стал создавать для поиска по сайту отдельный контроллер, а просто добавил метод в уже имеющейся котроллер.

Метод котроллера для обработки запроса формы


public function search(Request $request)
{
	$s = $request->s;
	$request->validate([
			's' => 'required',
	]);
	$products = Product::where('name', 'LIKE', "%{$s}%")->paginate(20);
	return view('front.catalog.search', compact('products', 's'));
}
  1. Тут мы сохраняем в переменную $s – запрос и валидируем полученные данные. Всего одно правило валидации – required, то есть значение обязательно, и в случае если пользователь отправит пустую форму, контролер вернет валидационную ошибку.
  2. Выбираем товары, соответствующие поисковой фразе, с использованием постраничной навигации.
  3. Возвращаем результат. Файл шаблона search.blade.php - и переменные: $s – содержание поискового запроса, и $products – с данными выбранных товаров, название которых соответствует запросу пользователя.

View – шаблон для вывода результатов поиска.


@section('content')
	
@forelse ($products as $product)

{{$product->name}}

//и другие данные товара @empty

По запросу {{$s}} товаров не найдено.

@endforelse
{{$products->links()}} @endsection

Тут все просто, в цикле blade шаблона выводим результаты поиска с пагинацией, или сообщение об отсутствии соответствующих запросу товаров.

Все, можно сказать, что поиск по каталогу работает. Однако, далеко не идеален, так как конструкция: Product::where('name', 'LIKE', "%{$s}%")->paginate(20); – в методе нашего контроллера, выберет только товары название которых строго соответствует запросу пользователя, точнее точному вхождению.

Предположим, что наш магазин расположен в Химках и в его каталоге содержится группа товаров:

  • Член деревянный 0,5 м;
  • Член деревянный 1 м;
  • Член деревянный 1,5 м и т.д.

Однако пользователь может ввести в форму поиска запрос, как член из дерева, деревянные члены, деревянный член, и т.д., и в таком случае результат поиска окажется пустым, так как нет точного соответствия вхождения запроса в название товаров.

Для решения данной проблемы, модифицируем наш запрос, а именно:

  1. удалим лишние символы и пробелы из запроса,
  2. разобьем запрос на отдельные слова,
  3. у каждого полученного слова уберем склонения, окончания, оставим только корень - часть слова,
  4. и наконец сделаем запрос, обращение к базе данных для поиска по каждой отдельной полученной части слова.

Для получения частей слова, удаления лишних окончаний воспользуемся алгоритмом Стеммером Портера.

Стеммер Портера — алгоритм нахождения основы слова, примененный Мартином Портером в 1980 году. Первая версия стеммера была создана для работы со словами на английском языке, и позже стала применятся для других, в том числе для русского языка.

Для использования алгоритма, можете воспользоваться композером и установить пакет ladamalina/php-lingua-stem-ru, однако при установке данного пакета могут возникнуть пробелы, так как данный пакет был написан под старые версии PHP и давно не обновлялся.

Но ничто на мешает нам воспользоваться готовым классом. Для этого создадим папку library в корне проекта Laravel, а в ней класс со следующим содержанием:

<?php
namespace library;

class LinguaStemRu
{
    var $VERSION = "0.02";
    var $Stem_Caching = 0;
    var $Stem_Cache = array();
    var $VOWEL = '/аеиоуыэюя/';
    var $PERFECTIVEGROUND = '/((ив|ивши|ившись|ыв|ывши|ывшись)|((?<=[ая])(в|вши|вшись)))$/';
    var $REFLEXIVE = '/(с[яь])$/';
    var $ADJECTIVE = '/(ее|ие|ые|ое|ими|ыми|ей|ий|ый|ой|ем|им|ым|ом|его|ого|еых|ую|юю|ая|яя|ою|ею)$/';
    var $PARTICIPLE = '/((ивш|ывш|ующ)|((?<=[ая])(ем|нн|вш|ющ|щ)))$/';
    var $VERB = '/((ила|ыла|ена|ейте|уйте|ите|или|ыли|ей|уй|ил|ыл|им|ым|ены|ить|ыть|ишь|ую|ю)|((?<=[ая])(ла|на|ете|йте|ли|й|л|ем|н|ло|но|ет|ют|ны|ть|ешь|нно)))$/';
    var $NOUN = '/(а|ев|ов|ие|ье|е|иями|ями|ами|еи|ии|и|ией|ей|ой|ий|й|и|ы|ь|ию|ью|ю|ия|ья|я)$/';
    var $RVRE = '/^(.*?[аеиоуыэюя])(.*)$/';
    var $DERIVATIONAL = '/[^аеиоуыэюя][аеиоуыэюя]+[^аеиоуыэюя]+[аеиоуыэюя].*(?<=о)сть?$/';

    function __construct() {
        mb_internal_encoding('UTF-8');
    }

    public function s(&$s, $re, $to)
    {
        $orig = $s;
        $s = preg_replace($re, $to, $s);
        return $orig !== $s;
    }

    public function m($s, $re)
    {
        return preg_match($re, $s);
    }

    public function stem_word($word)
    {
        $word = mb_strtolower($word);
        $word = str_replace('ё', 'е', $word); // замена ё на е, что бы учитывалась как одна и та же буква
        # Check against cache of stemmed words
        if ($this->Stem_Caching && isset($this->Stem_Cache[$word])) {
            return $this->Stem_Cache[$word];
        }
        $stem = $word;
        do {
            if (!preg_match($this->RVRE, $word, $p)) break;
            $start = $p[1];
            $RV = $p[2];
            if (!$RV) break;

            # Step 1
            if (!$this->s($RV, $this->PERFECTIVEGROUND, '')) {
                $this->s($RV, $this->REFLEXIVE, '');

                if ($this->s($RV, $this->ADJECTIVE, '')) {
                    $this->s($RV, $this->PARTICIPLE, '');
                } else {
                    if (!$this->s($RV, $this->VERB, ''))
                        $this->s($RV, $this->NOUN, '');
                }
            }

            # Step 2
            $this->s($RV, '/и$/', '');

            # Step 3
            if ($this->m($RV, $this->DERIVATIONAL))
                $this->s($RV, '/ость?$/', '');

            # Step 4
            if (!$this->s($RV, '/ь$/', '')) {
                $this->s($RV, '/ейше?/', '');
                $this->s($RV, '/нн$/', 'н');
            }

            $stem = $start.$RV;
        } while(false);
        if ($this->Stem_Caching) $this->Stem_Cache[$word] = $stem;
        return $stem;
    }

    /**
     * Стэмит все русские слова в тексте, оставляя пробелы и прочие знаки препинания на месте.
     * @param $text
     * @return string
     */
    public function stem_text($text)
    {
        $separators_arr= array('?',' ', '.', ',', ';','!','"','\'','`',"\r","\n","\t");
        $pos = 0;
        while($pos<mb_strlen($text)){
            $min_new_pos = mb_strlen($text);
            foreach ($separators_arr as $sep) {
                $newpos_candidate = mb_strpos($text, $sep, $pos);
                if($newpos_candidate!==FALSE) {
                    $min_new_pos = ($newpos_candidate < $min_new_pos) ? $newpos_candidate : $min_new_pos;
                }
            }
            $newpos = $min_new_pos;
            $word_part = mb_substr($text, $pos, $newpos-$pos);
            $word = preg_replace("/[^АБВГДЕЁЖЗИЙКЛМНОПРСТУФХЦЧШЩЪЫЬЭЮЯабвгдеёжзийклмнопрстуфхцчшщъыьэюя\x{2010}-]/u","",$word_part);
            if($word == ''){
                $pos = $newpos+1;
            }else{
                $word_stemmed = $this->stem_word($word);
                $word_stemmed_part = str_replace($word,$word_stemmed,$word_part);

                $text = mb_substr($text,0,$pos) . $word_stemmed_part . mb_substr($text, $newpos);

                $pos = $newpos - (mb_strlen($word)-mb_strlen($word_stemmed));
            }
        }
        return $text;
    }

    public function stem_caching($parm_ref)
    {
        $caching_level = @$parm_ref['-level'];
        if ($caching_level) {
            if (!$this->m($caching_level, '/^[012]$/')) {
                die(__CLASS__ . "::stem_caching() - Legal values are '0','1' or '2'. '$caching_level' is not a legal value");
            }
            $this->Stem_Caching = $caching_level;
        }
        return $this->Stem_Caching;
    }

    public function clear_stem_cache()
    {
        $this->Stem_Cache = array();
    }
}

Для выше перечисленных задач по модификации поисковой фразы, и построения запроса к базе данных создадим scope метод в модели Product:

public function scopeLike($query, $s)
{

	$s= iconv_substr($s, 0, 64);
	$s = preg_replace('#[^0-9a-zA-ZА-Яа-яёЁ]#u', ' ', $s);
	$s = preg_replace('#\s+#u', ' ', $s);
	$s = trim($s);

	if (empty($s)) {
			return $query->whereNull('id'); // возвращаем пустой результат
	}
	$temp = explode(' ', $s);
	$words = [];
	$stemmer = new \library\LinguaStemRu;
	foreach ($temp as $item) {
			if (iconv_strlen($item) > 3) {
					$words[] = $stemmer->stem_word($item);
			} else {
					$words[] = $item;
			}
	}
	$relevance = "IF (`products`.`name` LIKE '%" . $words[0] . "%', 2, 0)";
	for ($i = 1; $i < count($words); $i++) {
			$relevance .= " + IF (`products`.`name` LIKE '%" . $words[$i] . "%', 2, 0)";
	}
	$query->select('products.*', \DB::raw($relevance . ' as relevance'))
			->where('products.name', 'like', '%' . $words[0] . '%');
	for ($i = 1; $i < count($words); $i++) {
			$query = $query->orWhere('products.name', 'like', '%' . $words[$i] . '%');
	}
	$query->orderBy('relevance', 'desc');
	return $query;
}

И уже теперь, код в методе контроллера будет таким:

public function search(Request $request)
{
	$s = $request->s;
	$request->validate([
			's' => 'required',
	]);
	$products = Product::like($request->s)->paginate(20);
	return view('front.catalog.search', compact('products', 's'));
}

Таким образом, мы сделали максимально точный, соответствующий поисковой фразе, и при этом простой поиск товаров каталога сайта на фреймворке Laravel.

Автор: Максим Волков

Категории

Laravel

Комментарии