Как мы решаем проблемы со склонением слов для задач seo-оптимизации с помощью phpMorphy

80cd58bdb6c6e0664c82fda2f806e7c5.jpeg

Привет, друзья!

Мы разрабатываем платформу Я в агро. Платформа, помимо прочего, помогает найти вакансии в сфере агро.

Есть у нас задачка — генерировать уникальные страницы сайта, да так, чтобы и seo-заголовки для индексации поисковиками тоже генерировались автоматически под разные сущности проекта.

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

В нашем случае, у нас есть следующие сущности:

  1. Название вакансии или товарной позиции;

  2. Регион страны;

  3. Город.

На выходе мы хотим получить автоматизировано сгенерированный HTML контент, который бы имел текст описания вакансии, метатеги и seo-заголовки в коде.

Что нам нужно по SEO:

  1. title — заголовок

  2. h1 — заголовок первого уровня

  3. description — описание

  4. keywords — ключевые слова

Итак, пример. Допустим, у нас есть вакансия «Генетик», мы генерируем страницу для региона «Брянская область», то есть на выходе мы хотим получить набор различных вариантов контента, который мы применили бы в теле страницы сайта, возможно, и в сообщениях или письмах пользователям/подписчикам:

Вакансия генетика в Брянской области
Мы нашли 5 вакансий генетика в Брянской области
Работа генетиком, 32 вакансии
Поможем генетику найти работу в Брянской области
35 вакансий генетика в Брянске
Предложения работы генетиком в Севске Брянской области, 21 уникальная вакансия

Для SEO мы бы сгенерировали:

  1. title: Работа генетиком в Брянской области, 175 уникальных вакансий

  2. h1: Работа генетиком в Брянской области, 175 вакансий

  3. description: Работа в АПК. Свежие вакансии генетика в Брянской области от прямых работодателей. Мы собрали 175 уникальных вакансий

  4. keywords: работа генетиком, вакансии генетика, вакансии ассистента генетика, работа ассистентом генетика, вакансии генетик

Как это сделали мы?

Наш проект написан на Laravel (PHP), так что все примеры будут описаны для Laravel.

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

Скачать и установить phpMorphy можно, например, из этих 2х источников:

Для упрощения работы с phpMorphy мы создали свой Helper

Код нашего Helper’а (как есть)

 PHPMORPHY_STORAGE_MEM,
                'predict_by_suffix' => true,
                'predict_by_db' => true,
                'graminfo_as_text' => true,
            );
            return new phpMorphy($dir, $lang, $opts);
        } catch(phpMorphy_Exception $e) {
            log::error('Morphy::getMorphy(): Error occured while creating phpMorphy instance: ' . $e->getMessage());
        }
    }

    /**
     * Получение существительного для числового обозначения
     * Пример: 3 вакансии, 5 вакансий, 1 заявление
     *
     * @param $count
     * @param string $word
     * @param null $morphy
     * @return string
     */
    public static function getMorphedCount($count, string $word, $morphy = null): string
    {
        if (empty($morphy)) $morphy = self::getMorphy();
        $word = mb_strtoupper($word);
        $one = $morphy->castFormByGramInfo($word, null, ['ЕД', 'ИМ'], true)[0] ?? '';
        $twoFour = $morphy->castFormByGramInfo($word, null, ['ЕД', 'РД'], true)[0] ?? '';
        $fiveZero = $morphy->castFormByGramInfo($word, null, ['МН', 'РД'], true)[0] ?? '';
        switch (substr($count, -1)) {
            case '1': $countStr = $one; break;
            case '2':
            case '3':
            case '4': $countStr = $twoFour; break;
            case '5':
            case '6':
            case '7':
            case '8':
            case '9':
            case '0':
            default: $countStr = $fiveZero;
        }
        return mb_strtolower($countStr);
    }

    /**
     * Преобразование слова к указанному падежу и численности
     *
     * @param $morphy
     * @param string $word
     * @param array $gramInfo
     * @return string
     */
    public static function getMorphed($morphy, string $word, array $gramInfo): string
    {
        try {
            $delims = collect(self::MORPHY_SKEEP_WORD_DELIMS);

            $word = str_replace(' - ', '-', $word);

            $delimsFounded = $delims->filter(function ($delim) use ($word){ return mb_strpos($word, $delim) !== false; });

            if ($delimsFounded->count() > 0){
                //Преобразование фразы, делаем каждое слово отдельно
                $resultWord = '';
                $delimsFounded->each(function ($delim) use ($word, &$result, $morphy, $gramInfo, &$resultWord){
                    $result = [];
                    collect(explode($delim, $word))->each(function ($word) use (&$result, $morphy, $gramInfo){
                        $result[] = self::isWordEndWithSkipArr($word) ? $word : ($morphy->castFormByGramInfo(mb_strtoupper($word), null, $gramInfo, true)[0]??'');
                    });
                    $resultWord = implode($delim, $result);
                });
                return mb_strtolower($resultWord);
            } else {
                //Преобразование слова
                return mb_strtolower($morphy->castFormByGramInfo(mb_strtoupper($word), null, $gramInfo, true)[0] ?? '');
            }
        } catch (\Throwable $err) {
            \Illuminate\Support\Facades\Log::error('Morphy::getMorphed(): EXCEPTION. $word: '.$word.' $gramInfo: '.json_encode($gramInfo).' '.$err->getMessage(), ['ERROR' => $err->getMessage(), 'TRACE' => $err->getTrace()]);
            return '';
        }
    }

    /**
     * Проверка, заканчивается ли слово на одно из значений массива
     *
     * @param $word
     * @return bool
     */
    public static function isWordEndWithSkipArr($word){
        return collect(self::MORPHY_SKEEP_WORD_END_WITH)->filter(function ($skipLetter) use ($word){
                return ends_with(mb_strtolower($word), mb_strtolower($skipLetter));
            })->count() > 0;
    }

    /**
     * Привести первую буква слова к верхнему регистру в кодировке UTF-8
     *
     * @param $text
     * @return string
     */
    public static function mb_ucfirst($text): string
    {
        try {
            $text = mb_strtolower($text);
            return mb_strtoupper(mb_substr($text, 0, 1)) . mb_substr($text, 1);
        } catch (\Throwable $err) {
            \Illuminate\Support\Facades\Log::error('Morphy::mb_ucfirst(): EXCEPTION. $text: '.$text.' '.$err->getMessage(), ['ERROR' => $err->getMessage(), 'TRACE' => $err->getTrace()]);
            return '';
        }
    }

    /**
     * Привести первую букву каждого слова в строке к верхнему регистру в кодировке UTF-8
     *
     * @param $text
     * @return string
     */
    public static function mb_ucwords($text): string
    {
        try {
            $text = mb_strtolower($text);
            $result = [];
            collect(explode(' ', $text))->each(function ($word) use (&$result) {
                $result[] = self::mb_ucfirst($word);
            });
            return implode(' ', $result);
        } catch (\Throwable $err) {
            \Illuminate\Support\Facades\Log::error('Morphy::mb_ucwords(): EXCEPTION. $text: '.$text.' '.$err->getMessage(), ['ERROR' => $err->getMessage(), 'TRACE' => $err->getTrace()]);
            return '';
        }
    }

    public static function getMorphedRegionName($morphy, string $regionName, bool $addV = true)
    {
        //В республике
        if (mb_strpos($regionName, ' ') === false) return $regionName;

        $regionName = mb_strtoupper(str_replace(' — ','-', $regionName));

        $result = $regionName;
        $regionArr = explode(' ', $regionName);

        $v = $addV ? 'в ' : '';

        if (mb_strpos($regionName, 'РЕСПУБЛИКА') !== false){
            if (starts_with($regionName, 'РЕСПУБЛИКА')){
                //Республика Крым -> в республике Крым
                $result = $v.'республике '.Morphy::mb_ucfirst(mb_strtolower(str_replace('РЕСПУБЛИКА ', '', $regionName)));
            } else {
                //Чувашская Республика -> в Чувашской республике
                $result = $v.Morphy::mb_ucfirst(Morphy::getMorphed($morphy, $regionArr[0], ['ЕД', 'ЖР', 'РД'])).' республике';
            }

        } elseif (mb_strpos($regionName, 'КРАЙ') !== false){
            //Алтайский край -> в Алтайском крае
            $result = $v.Morphy::mb_ucfirst(Morphy::getMorphed($morphy, $regionArr[0], ['ЕД', 'МР', 'ПР'])).' крае';
        } elseif (mb_strpos($regionName, 'АВТОНОМНАЯ ОБЛАСТЬ') !== false){
            //в Еврейской автономной области
            $result = $v.Morphy::mb_ucfirst(Morphy::getMorphed($morphy, $regionArr[0], ['ЕД', 'ЖР', 'РД'])).' автономной области';
        } elseif (mb_strpos($regionName, 'ОБЛАСТЬ') !== false){
            //в Архангельской области
            $result = $v.Morphy::mb_ucfirst(Morphy::getMorphed($morphy, $regionArr[0], ['ЕД', 'ЖР', 'РД'])).' области';
        } elseif (mb_strpos($regionName, 'АВТОНОМНЫЙ ОКРУГ') !== false){
            //в Ханты-Мансийском автономном округе
            $result = $v.Morphy::mb_ucfirst(Morphy::getMorphed($morphy, $regionArr[0], ['ЕД', 'МР', 'ПР'])).' автономном округе';
        }

        return $result;
    }
}

Что есть в Helper’е:

  1. getMorphy () — создает класс phpMorphy с предустановленными настройками;

  2. getMorphedCount ($count, string $word, $morphy = null) — склоняет существительное для числового отображения. Пример: 5 вакансий, 101 рудокоп.

  3. getMorphed ($morphy, string $word, array $gramInfo) — применяет к слову или фразе указанные правила преобразования

  4. isWordEndWithSkipArr ($word) — используется для пропуска слов-исключений, нам это нужно для регионов

  5. mb_ucfirst ($text) — делает первую букву первого слова в строке заглавной, работает корректно с кириллицей и разделителями в строке, в отличии от штатного средства. Имя метода специально сделано максимально приближенным к штатному.

  6. mb_ucwords ($text) — то же самое, что и предыдущий метод, только делает первую букву заглавной в каждом слове строки

  7. getMorphedRegionName ($morphy, string $regionName, bool $addV = true) — приводит регион к нужному склонению. Пример: «в Краснодарском крае».

Вся магия происходит в методе phpMorthy→castFormByGramInfo ()

castFormByGramInfo($word, $partOfSpeech, $grammems, $returnOnlyWord = false, $callback = null, $type = self::NORMAL)

Для текущей задачи мы используем этот метод так:

$morphy->castFormByGramInfo(mb_strtoupper($word), null, $gramInfo, true)

Метод castFormByGramInfo () отработает некорректно, если получит на входе слово или фразу не в формате заглавных букв (UPPER_CASE), поэтому был создан метод mb_strtoupper (). Повторюсь, штатный метод приведения ко всем заглавным буквам плохо работает с кириллицей в купе с разделителями, поэтому мы создали свой метод.

$gramInfo — это правила приведения, массив.

Мы применяем правила вида:

[{КОД_ЧИСЛЕННОСТИ}, {КОД_РОДА}, {КОД_ПАДЕЖА}]

Пример:

['ЕД', 'ЖР', 'РД']

Подробно коды описаны в документации phpMorphy: http://phpmorphy.sourceforge.net/dokuwiki/manual-graminfo

Пример: ['ЕД', 'ЖР', 'РД'] — Единственное число, женский род, родительный падеж

Применив данные правила к слову «Чувашский», мы получим «Чувашской».

Возможности phpMorphy при должном использовании поражают.

Еще пример:

return 'Работа '.Morphy::getMorphed($morphy, $name, ['ЕД', 'ТВ']);

Данный код вернет вакансию в творительном падеже, «Работа генетиком».

Если Вам сложно подобрать правила для Вашего случая, можно воспользоваться методом phpMorphy:

$morphy->getAllFormsWithGramInfo('ТЕСТ', true)

Данный метод выдаст массивом все варианты слов на основе Вашего примера и покажет какие правила привели к каждой форме слова.

Также phpMorphy умеет приводить слова по образцу:

$morphy->castFormByPattern('ДИВАН', 'СТОЛАМИ', null, true)

Так мы получим слово «ДИВАНАМИ» по образцу в виде слова «СТОЛАМИ».

Практически во всех методах Helper’а мы передает заранее созданный экземпляр phpMorphy в переменной $morphy, чтобы не создавать его при каждом обращении к Helper’у, например при обработке коллекции данных (генерации страниц).

Пример метода генерации SEO данных

     /**
     * Генерим SOE данные для группы вакансий
     *
     * @return array
     */
    public function getSeoDataForGroup($morphy, $count, $groupName, $regionName): array
    {
        // Хотим на выходе:
        // title: Работа биоинженером в России, 175 уникальных вакансий
        // h1: Работа биоинженером в России, 175 вакансий
        // description: Работа в АПК. Свежие вакансии биоинженера в России от прямых работодателей. Мы собрали 175 уникальных вакансий.
        // keywords: работа биоинженером, вакансии биоинженера, вакансии ассистента биоинженера, работа ассистентом биоинженера, вакансии биоинженер

        $groupName = mb_strtolower($groupName);
        $regionName = mb_strtolower($regionName);

        $groupNameTV = Morphy::getMorphed($morphy, $groupName, ['ЕД', 'ТВ']); //кем - биоинженером - Творительный падеж
        $groupNameVN = Morphy::getMorphed($morphy, $groupName, ['ЕД', 'ВН']); //кого - биоинженера - Винительный падеж

        $regionNameV = Morphy::getMorphedRegionName($morphy, $regionName);

        $countStr = Morphy::getMorphedCount($count, 'вакансия', $morphy);

        $titlePart = "Работа $groupNameTV $regionNameV, $count"; //Работа биоинженером в России, 175
        $descripton = "Работа в АПК. Свежие вакансии $groupNameVN $regionNameV от прямых работодателей. Мы собрали $count уникальных $countStr.";
        $keywords = "работа $groupNameTV, вакансии $groupNameVN, вакансии ассистента $groupNameVN, работа ассистентом $groupNameVN, вакансии $groupName";


        return [
            'seo_title'         => "$titlePart уникальных $countStr",
            'seo_h1'            => "$titlePart $countStr",
            'seo_description'   => $descripton,
            'seo_keywords'      => $keywords
        ];
    }

Далее, после генерации контента страниц и SEO-заголовков, для каждой страницы мы сохраняем запись в специальную табличку базы данных, с генерацией уникального пути к странице сайта (url).

Мы настроили наш веб сервер таким образом, что при запросе sitemap поисковиком, он генерируется на основе данных вышеописанной таблице БД. Таким образом, все сгенерированные данные индексируются поисковиками.

Заключение

Благодаря phpMorphy Вы можете генерировать практически любой контент и корректные SEO-заголовки. Данный подход также применим и для генерации метаданных.

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

Очень надеемся, что кому-нибудь помогли данной статьей.

С уважением и спасибо за внимание!

Александр Корабельников, Россельхозбанк.

© Habrahabr.ru