1780 lines
65 KiB
PHP
1780 lines
65 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace App\Services;
|
||
|
||
final class SmartSearchService
|
||
{
|
||
private const STOP_WORDS = [
|
||
'и', 'или', 'для', 'под', 'на', 'в', 'во', 'с', 'со', 'из', 'от', 'до', 'по', 'за', 'к', 'ко',
|
||
'купить', 'нужно', 'нужна', 'нужен', 'хочу', 'хотим', 'можно', 'товар', 'товары', 'продукт',
|
||
];
|
||
|
||
private const TOKEN_SYNONYMS = [
|
||
'горбуша' => ['красная', 'рыба', 'лососевые', 'дикая', 'засолка', 'жарка', 'целая'],
|
||
'кета' => ['красная', 'рыба', 'лососевые', 'дикая', 'засолка', 'жарка'],
|
||
'кижуч' => ['красная', 'рыба', 'лососевые', 'дикая', 'засолка'],
|
||
'нерка' => ['красная', 'рыба', 'лососевые', 'дикая', 'премиум'],
|
||
'семга' => ['лосось', 'красная', 'рыба', 'лососевые', 'стейк', 'филе', 'слабосоленая'],
|
||
'лосось' => ['семга', 'красная', 'рыба', 'лососевые', 'стейк', 'филе'],
|
||
'форель' => ['красная', 'рыба', 'лососевые', 'стейк', 'филе', 'засолка'],
|
||
'чавыча' => ['красная', 'рыба', 'лососевые', 'дикая'],
|
||
'голец' => ['красная', 'рыба', 'лососевые', 'дикая'],
|
||
'неразделанная' => ['целая', 'тушка', 'сголовой', 'засолка', 'жарка'],
|
||
'целая' => ['неразделанная', 'тушка', 'сголовой'],
|
||
'потрошеная' => ['разделанная', 'безголовы', 'пбг', 'тушка'],
|
||
'разделанная' => ['потрошеная', 'безголовы', 'пбг', 'тушка'],
|
||
'пбг' => ['потрошеная', 'разделанная', 'безголовы'],
|
||
'филе' => ['безкостей', 'пласт', 'кубик', 'лойн'],
|
||
'дори' => ['пангасиус', 'белая', 'рыба', 'рыбное', 'филе', 'аквакультура'],
|
||
'пангасиус' => ['дори', 'белая', 'рыба', 'рыбное', 'филе', 'аквакультура'],
|
||
'стейк' => ['стейки', 'нарезка', 'порционно', 'гриль', 'жарка'],
|
||
'стейки' => ['стейк', 'нарезка', 'порционно', 'гриль', 'жарка'],
|
||
'фарш' => ['котлеты', 'полуфабрикаты'],
|
||
'копченая' => ['копчение', 'горячего', 'холодного', 'закуска'],
|
||
'копчение' => ['копченая', 'горячего', 'холодного', 'закуска'],
|
||
'слабосоленая' => ['малосольная', 'посол', 'закуска'],
|
||
'малосольная' => ['слабосоленая', 'посол', 'закуска'],
|
||
'вяленая' => ['сушеная', 'закуска', 'пиво'],
|
||
'сушеная' => ['вяленая', 'закуска', 'пиво'],
|
||
'икра' => ['деликатес', 'красная', 'банка', 'праздник'],
|
||
'консервы' => ['пресервы', 'банка', 'жб', 'рыба'],
|
||
'пресервы' => ['консервы', 'банка', 'рыба', 'слабосоленая'],
|
||
'креветки' => ['креветка', 'морепродукты', 'лангустины', 'северные', 'тигровые', 'ванамей'],
|
||
'креветка' => ['креветки', 'морепродукты', 'лангустины', 'северные', 'тигровые', 'ванамей'],
|
||
'мидии' => ['морепродукты', 'раковина', 'киви'],
|
||
'кальмар' => ['морепродукты', 'кольца', 'тушка', 'щупальца'],
|
||
'краб' => ['морепродукты', 'фаланга', 'клешни', 'мясо'],
|
||
'мясо' => ['говядина', 'свинина', 'баранина', 'курица', 'индейка', 'гриль', 'шашлык'],
|
||
'сыр' => ['сыры', 'молочная', 'плавленый', 'твердый'],
|
||
'сыры' => ['сыр', 'молочная', 'плавленый', 'твердый'],
|
||
'овощи' => ['смесь', 'заморозка', 'гарнир'],
|
||
'ягоды' => ['заморозка', 'компот', 'десерт'],
|
||
'грибы' => ['заморозка', 'маринованные'],
|
||
'бакалея' => ['соус', 'крупа', 'масло', 'специи', 'приправы'],
|
||
'оптом' => ['большая', 'фасовка', 'короб', 'мешок', 'монолитная'],
|
||
'розница' => ['маленькая', 'фасовка', '1кг', '0.5кг', 'фасованная'],
|
||
'мешок' => ['мешками', 'оптом', 'большая', 'фасовка'],
|
||
'мешками' => ['мешок', 'оптом', 'большая', 'фасовка'],
|
||
'короб' => ['оптом', 'большая', 'фасовка'],
|
||
'монолитная' => ['короб', 'большая', 'фасовка', 'оптом'],
|
||
'заморозка' => ['свежемороженая', 'мороженая', 'хранение', 'морозильная'],
|
||
'свежемороженая' => ['заморозка', 'мороженая', 'морозильная'],
|
||
'охлажденная' => ['свежая', 'холодильная', 'деньвдень'],
|
||
'свежая' => ['охлажденная', 'холодильная'],
|
||
'гриль' => ['мангал', 'дача', 'стейк', 'шашлык', 'купаты', 'жарка'],
|
||
'мангал' => ['гриль', 'дача', 'стейк', 'шашлык', 'купаты', 'жарка'],
|
||
'дача' => ['гриль', 'мангал', 'шашлык', 'стейки', 'рыба', 'мясо'],
|
||
'ужин' => ['рыба', 'мясо', 'филе', 'стейки', 'овощи', 'полуфабрикаты'],
|
||
];
|
||
|
||
private const PHRASE_SYNONYMS = [
|
||
'под засол' => ['красная', 'рыба', 'горбуша', 'кета', 'нерка', 'форель', 'семга', 'неразделанная', 'целая'],
|
||
'для засол' => ['красная', 'рыба', 'горбуша', 'кета', 'нерка', 'форель', 'семга', 'неразделанная', 'целая'],
|
||
'для жар' => ['стейки', 'филе', 'тушка', 'рыба', 'мясо', 'гриль'],
|
||
'на жар' => ['стейки', 'филе', 'тушка', 'рыба', 'мясо', 'гриль'],
|
||
'к пиву' => ['вяленая', 'сушеная', 'копченая', 'закуска'],
|
||
'красная рыба' => ['горбуша', 'кета', 'кижуч', 'нерка', 'семга', 'лосось', 'форель', 'чавыча', 'голец'],
|
||
'белая рыба' => ['треска', 'минтай', 'пикша', 'окунь', 'судак', 'хек', 'палтус', 'камбала', 'пангасиус', 'дори', 'морской язык'],
|
||
'филе дори' => ['пангасиус', 'белая', 'рыба', 'рыбное', 'филе', 'аквакультура'],
|
||
'белый пангасиус' => ['дори', 'белая', 'рыба', 'рыбное', 'филе', 'аквакультура'],
|
||
'филе белой рыбы' => ['треска', 'минтай', 'пикша', 'окунь', 'судак', 'хек', 'палтус', 'камбала', 'пангасиус', 'дори'],
|
||
'большая фасовка' => ['оптом', 'короб', 'мешок', 'монолитная'],
|
||
'маленькая фасовка' => ['розница', 'фасованная', '1кг', '0.5кг'],
|
||
'частным лицам' => ['розница', 'маленькая', 'фасовка', 'дом', 'ужин'],
|
||
'для ресторана' => ['horeca', 'оптом', 'большая', 'фасовка'],
|
||
'для магазина' => ['оптом', 'большая', 'фасовка', 'витрина'],
|
||
];
|
||
|
||
private const PHRASE_REQUIRED_ANY = [
|
||
'икра красной рыбы' => [
|
||
'икра',
|
||
'красная икра',
|
||
'икра лососевых',
|
||
'икра горбуши',
|
||
'икра кеты',
|
||
'икра нерки',
|
||
'икра кижуча',
|
||
'икра форели',
|
||
],
|
||
'красная икра' => [
|
||
'икра',
|
||
'красная икра',
|
||
'икра лососевых',
|
||
'икра горбуши',
|
||
'икра кеты',
|
||
'икра нерки',
|
||
'икра кижуча',
|
||
'икра форели',
|
||
],
|
||
'красная рыба' => [
|
||
'красная рыба',
|
||
'лососевые',
|
||
'горбуша',
|
||
'кета',
|
||
'кижуч',
|
||
'нерка',
|
||
'семга',
|
||
'лосось',
|
||
'форель',
|
||
'чавыча',
|
||
'голец',
|
||
'брюшки лосося',
|
||
'молоки лососевые',
|
||
],
|
||
'белая рыба' => [
|
||
'белая рыба',
|
||
'треска',
|
||
'минтай',
|
||
'пикша',
|
||
'окунь',
|
||
'судак',
|
||
'хек',
|
||
'палтус',
|
||
'камбала',
|
||
'сайда',
|
||
'навага',
|
||
'дори',
|
||
'морской язык',
|
||
'лемонема',
|
||
'макрурус',
|
||
'путассу',
|
||
'дорадо',
|
||
'сибас',
|
||
'зубатка',
|
||
'мойва',
|
||
'сельдь',
|
||
'скумбрия',
|
||
'терпуг',
|
||
'щука',
|
||
'сазан',
|
||
'карась',
|
||
'карп',
|
||
'кефаль',
|
||
'пангасиус',
|
||
'тилапия',
|
||
],
|
||
];
|
||
|
||
private const PHRASE_REQUIRED_ALL = [
|
||
'икра красной рыбы' => ['икра'],
|
||
'красная икра' => ['икра'],
|
||
'филе белой рыбы' => ['филе'],
|
||
];
|
||
|
||
private const PHRASE_DIRECT_HINTS = [
|
||
'икра красной рыбы' => ['икра', 'красная икра', 'икра лососевых'],
|
||
'красная икра' => ['икра', 'красная икра', 'икра лососевых'],
|
||
'филе белой рыбы' => ['филе', 'рыбное филе', 'белая рыба'],
|
||
'красная рыба' => ['красная рыба', 'лососевые'],
|
||
'белая рыба' => ['белая рыба'],
|
||
'рыбные консервы' => ['консервы', 'пресервы', 'жб', 'банка'],
|
||
'рыбные пресервы' => ['консервы', 'пресервы', 'жб', 'банка'],
|
||
];
|
||
|
||
private const TOKEN_REQUIRED_ANY = [
|
||
'рыба' => [
|
||
'рыба',
|
||
'рыбное филе',
|
||
'рыбные стейки',
|
||
'красная рыба',
|
||
'белая рыба',
|
||
'охлажденная рыба',
|
||
'копченая рыба',
|
||
'вяленая рыба',
|
||
'малосольная рыба',
|
||
'горбуша',
|
||
'кета',
|
||
'кижуч',
|
||
'нерка',
|
||
'семга',
|
||
'лосось',
|
||
'форель',
|
||
'треска',
|
||
'минтай',
|
||
'пикша',
|
||
'окунь',
|
||
'судак',
|
||
'хек',
|
||
'палтус',
|
||
'камбала',
|
||
'сельдь',
|
||
'скумбрия',
|
||
'щука',
|
||
],
|
||
'сыр' => [
|
||
'сыр',
|
||
'сыры',
|
||
'твердые сыры',
|
||
'полутвердые',
|
||
'рассольные сыры',
|
||
'с плесенью',
|
||
'мягкие сыры',
|
||
'брынза',
|
||
'моцарелла',
|
||
'сулугуни',
|
||
'камамбер',
|
||
'бри',
|
||
'пармезан',
|
||
'гауда',
|
||
'чеддер',
|
||
'президент',
|
||
],
|
||
'мясо' => [
|
||
'мясо',
|
||
'говядина',
|
||
'свинина',
|
||
'баранина',
|
||
'курица',
|
||
'кролик',
|
||
'индейка',
|
||
'телятина',
|
||
'утка',
|
||
'гусь',
|
||
'буйвол',
|
||
'субпродукты',
|
||
'мясные полуфабрикаты',
|
||
],
|
||
'икра' => [
|
||
'икра',
|
||
'красная икра',
|
||
'икра сельди',
|
||
'икра минтая',
|
||
'икра палтуса',
|
||
'икра трески',
|
||
'икра щуки',
|
||
'масаго',
|
||
'тобико',
|
||
],
|
||
'морепродукты' => [
|
||
'морепродукты',
|
||
'креветки',
|
||
'кальмар',
|
||
'краб',
|
||
'мидии',
|
||
'морской гребешок',
|
||
'осьминог',
|
||
'лангустины',
|
||
'вонголе',
|
||
'раки',
|
||
],
|
||
'консервы' => [
|
||
'консервы',
|
||
'пресервы',
|
||
'рыбные консервы',
|
||
'рыбные пресервы',
|
||
'жб',
|
||
'банка',
|
||
'в масле',
|
||
'в томатном соусе',
|
||
'овощная консервация',
|
||
],
|
||
'пресервы' => [
|
||
'пресервы',
|
||
'консервы',
|
||
'рыбные пресервы',
|
||
'рыбные консервы',
|
||
'в масле',
|
||
'слабосоленая',
|
||
'банка',
|
||
'ведро',
|
||
],
|
||
];
|
||
|
||
private const PHRASE_EXCLUDED_ANY = [
|
||
'икра красной рыбы' => [
|
||
'неразделанная',
|
||
'разделанная',
|
||
'потрошеная',
|
||
'без головы',
|
||
'с головой',
|
||
'тушка',
|
||
'стейки',
|
||
'филе',
|
||
'фарш',
|
||
'молоки',
|
||
'брюшки',
|
||
'теша',
|
||
'хребты',
|
||
'балык',
|
||
'морепродукты',
|
||
'мясо',
|
||
'сыры',
|
||
'бакалея',
|
||
'овощи',
|
||
'ягоды',
|
||
'икра минтая',
|
||
'икра палтуса',
|
||
'икра трески',
|
||
'икра щуки',
|
||
'икра сельди',
|
||
'масаго',
|
||
'тобико',
|
||
'имитированная',
|
||
],
|
||
'красная икра' => [
|
||
'неразделанная',
|
||
'разделанная',
|
||
'потрошеная',
|
||
'без головы',
|
||
'с головой',
|
||
'тушка',
|
||
'стейки',
|
||
'филе',
|
||
'фарш',
|
||
'молоки',
|
||
'брюшки',
|
||
'теша',
|
||
'хребты',
|
||
'балык',
|
||
'морепродукты',
|
||
'мясо',
|
||
'сыры',
|
||
'бакалея',
|
||
'овощи',
|
||
'ягоды',
|
||
'икра минтая',
|
||
'икра палтуса',
|
||
'икра трески',
|
||
'икра щуки',
|
||
'икра сельди',
|
||
'масаго',
|
||
'тобико',
|
||
'имитированная',
|
||
],
|
||
'красная рыба' => [
|
||
'красная смородина',
|
||
'фасоль',
|
||
'чечевица',
|
||
'икра',
|
||
'морепродукты',
|
||
'мясо',
|
||
'сыры',
|
||
'овощи',
|
||
'ягоды',
|
||
'бакалея',
|
||
],
|
||
'белая рыба' => [
|
||
'красная рыба',
|
||
'лососевые',
|
||
'горбуша',
|
||
'кета',
|
||
'кижуч',
|
||
'нерка',
|
||
'семга',
|
||
'лосось',
|
||
'форель',
|
||
'чавыча',
|
||
'голец',
|
||
'морепродукты',
|
||
'гребешок',
|
||
'креветки',
|
||
'кальмар',
|
||
'краб',
|
||
'мидии',
|
||
'осьминог',
|
||
'лангустины',
|
||
'вонголе',
|
||
'чука',
|
||
'угорь',
|
||
'раки',
|
||
'каракатица',
|
||
'улитки',
|
||
'мясо',
|
||
'говядина',
|
||
'свинина',
|
||
'баранина',
|
||
'курица',
|
||
'индейка',
|
||
'телятина',
|
||
'утка',
|
||
'гусь',
|
||
'кролик',
|
||
'буйвол',
|
||
'язык',
|
||
'печень',
|
||
'сердце',
|
||
'филей',
|
||
'лопатка',
|
||
'оковалок',
|
||
'карбонад',
|
||
'вырезка',
|
||
'ребра',
|
||
'фарш мясной',
|
||
'сыр',
|
||
'сыры',
|
||
'сырный',
|
||
'сырная',
|
||
'молочная',
|
||
'плесень',
|
||
'плесенью',
|
||
'брынза',
|
||
'моцарелла',
|
||
'сулугуни',
|
||
'камамбер',
|
||
'бри',
|
||
'пармезан',
|
||
'президент',
|
||
'овощи',
|
||
'ягоды',
|
||
'бакалея',
|
||
],
|
||
'филе белой рыбы' => [
|
||
'красная рыба',
|
||
'лососевые',
|
||
'горбуша',
|
||
'кета',
|
||
'кижуч',
|
||
'нерка',
|
||
'семга',
|
||
'лосось',
|
||
'форель',
|
||
'чавыча',
|
||
'голец',
|
||
'морепродукты',
|
||
'гребешок',
|
||
'креветки',
|
||
'кальмар',
|
||
'краб',
|
||
'мидии',
|
||
'осьминог',
|
||
'лангустины',
|
||
'вонголе',
|
||
'чука',
|
||
'угорь',
|
||
'раки',
|
||
'каракатица',
|
||
'улитки',
|
||
'мясо',
|
||
'говядина',
|
||
'свинина',
|
||
'баранина',
|
||
'курица',
|
||
'индейка',
|
||
'телятина',
|
||
'утка',
|
||
'гусь',
|
||
'кролик',
|
||
'буйвол',
|
||
'язык',
|
||
'печень',
|
||
'сердце',
|
||
'филей',
|
||
'лопатка',
|
||
'оковалок',
|
||
'карбонад',
|
||
'вырезка',
|
||
'ребра',
|
||
'фарш мясной',
|
||
'сыр',
|
||
'сыры',
|
||
'сырный',
|
||
'сырная',
|
||
'молочная',
|
||
'плесень',
|
||
'плесенью',
|
||
'брынза',
|
||
'моцарелла',
|
||
'сулугуни',
|
||
'камамбер',
|
||
'бри',
|
||
'пармезан',
|
||
'президент',
|
||
],
|
||
];
|
||
|
||
private const STRICT_INTENTS = [
|
||
'икра красной рыбы' => [
|
||
'required_any' => [
|
||
'икра',
|
||
'красная икра',
|
||
'икра лососевых',
|
||
'икра горбуши',
|
||
'икра кеты',
|
||
'икра нерки',
|
||
'икра кижуча',
|
||
'икра форели',
|
||
],
|
||
'required_all' => ['икра'],
|
||
'excluded_any' => [
|
||
'неразделанная',
|
||
'разделанная',
|
||
'потрошеная',
|
||
'без головы',
|
||
'с головой',
|
||
'тушка',
|
||
'стейки',
|
||
'филе',
|
||
'фарш',
|
||
'молоки',
|
||
'брюшки',
|
||
'теша',
|
||
'хребты',
|
||
'балык',
|
||
'морепродукты',
|
||
'мясо',
|
||
'сыры',
|
||
'бакалея',
|
||
'овощи',
|
||
'ягоды',
|
||
'икра минтая',
|
||
'икра палтуса',
|
||
'икра трески',
|
||
'икра щуки',
|
||
'икра сельди',
|
||
'масаго',
|
||
'тобико',
|
||
'имитированная',
|
||
],
|
||
],
|
||
'красная рыба' => [
|
||
'required_any' => [
|
||
'красная рыба',
|
||
'лососевые',
|
||
'горбуша',
|
||
'кета',
|
||
'кижуч',
|
||
'нерка',
|
||
'семга',
|
||
'лосось',
|
||
'форель',
|
||
'чавыча',
|
||
'голец',
|
||
'брюшки лосося',
|
||
'молоки лососевые',
|
||
],
|
||
'required_all' => [],
|
||
'excluded_any' => [
|
||
'белая рыба',
|
||
'морепродукты',
|
||
'мясо',
|
||
'сыры',
|
||
'сыр',
|
||
'молочная продукция',
|
||
'бакалея',
|
||
'овощи',
|
||
'ягоды',
|
||
'грибы',
|
||
'красная смородина',
|
||
'красная фасоль',
|
||
'чечевица красная',
|
||
'икра',
|
||
],
|
||
],
|
||
'белая рыба' => [
|
||
'required_any' => [
|
||
'белая рыба',
|
||
'треска',
|
||
'минтай',
|
||
'пикша',
|
||
'окунь',
|
||
'судак',
|
||
'хек',
|
||
'палтус',
|
||
'камбала',
|
||
'сайда',
|
||
'навага',
|
||
'дори',
|
||
'морской язык',
|
||
'лемонема',
|
||
'макрурус',
|
||
'путассу',
|
||
'дорадо',
|
||
'сибас',
|
||
'зубатка',
|
||
'мойва',
|
||
'сельдь',
|
||
'скумбрия',
|
||
'терпуг',
|
||
'щука',
|
||
'сазан',
|
||
'карась',
|
||
'карп',
|
||
'кефаль',
|
||
'пангасиус',
|
||
'тилапия',
|
||
'ледяная рыба',
|
||
'конгрио',
|
||
'корюшка',
|
||
],
|
||
'required_all' => [],
|
||
'excluded_any' => [
|
||
'красная рыба',
|
||
'лососевые',
|
||
'горбуша',
|
||
'кета',
|
||
'кижуч',
|
||
'нерка',
|
||
'семга',
|
||
'лосось',
|
||
'форель',
|
||
'чавыча',
|
||
'голец',
|
||
'морепродукты',
|
||
'гребешок',
|
||
'креветки',
|
||
'кальмар',
|
||
'краб',
|
||
'мидии',
|
||
'осьминог',
|
||
'лангустины',
|
||
'вонголе',
|
||
'чука',
|
||
'раки',
|
||
'улитки',
|
||
'мясо',
|
||
'говядина',
|
||
'свинина',
|
||
'баранина',
|
||
'курица',
|
||
'индейка',
|
||
'телятина',
|
||
'утка',
|
||
'гусь',
|
||
'буйвол',
|
||
'филейный край',
|
||
'сыр',
|
||
'сыры',
|
||
'сырная',
|
||
'сырный',
|
||
'молочная продукция',
|
||
'плесень',
|
||
'бри',
|
||
'камамбер',
|
||
'моцарелла',
|
||
'овощи',
|
||
'ягоды',
|
||
'грибы',
|
||
'бакалея',
|
||
],
|
||
],
|
||
'филе белой рыбы' => [
|
||
'required_any' => [
|
||
'белая рыба',
|
||
'рыбное филе',
|
||
'филе дори',
|
||
'филе пангасиуса',
|
||
'филе трески',
|
||
'филе минтая',
|
||
'филе пикши',
|
||
'филе окуня',
|
||
'филе судака',
|
||
'филе хека',
|
||
'филе палтуса',
|
||
'филе камбалы',
|
||
'филе сайды',
|
||
'филе тилапии',
|
||
'филе кефали',
|
||
'треска',
|
||
'минтай',
|
||
'пикша',
|
||
'окунь',
|
||
'судак',
|
||
'хек',
|
||
'палтус',
|
||
'камбала',
|
||
'сайда',
|
||
'дори',
|
||
'морской язык',
|
||
'пангасиус',
|
||
'тилапия',
|
||
'кефаль',
|
||
],
|
||
'required_all' => ['филе'],
|
||
'excluded_any' => [
|
||
'красная рыба',
|
||
'лососевые',
|
||
'горбуша',
|
||
'кета',
|
||
'кижуч',
|
||
'нерка',
|
||
'семга',
|
||
'лосось',
|
||
'форель',
|
||
'чавыча',
|
||
'голец',
|
||
'морепродукты',
|
||
'морской гребешок',
|
||
'гребешок',
|
||
'креветки',
|
||
'кальмар',
|
||
'краб',
|
||
'мидии',
|
||
'осьминог',
|
||
'лангустины',
|
||
'вонголе',
|
||
'чука',
|
||
'раки',
|
||
'улитки',
|
||
'мясо',
|
||
'говядина',
|
||
'свинина',
|
||
'баранина',
|
||
'курица',
|
||
'индейка',
|
||
'телятина',
|
||
'утка',
|
||
'гусь',
|
||
'буйвол',
|
||
'филейный край',
|
||
'филе бедра',
|
||
'филе грудки',
|
||
'филе утки',
|
||
'филе индейки',
|
||
'филе куриное',
|
||
'сыр',
|
||
'сыры',
|
||
'сырная',
|
||
'сырный',
|
||
'молочная продукция',
|
||
'плесень',
|
||
'плесенью',
|
||
'бри',
|
||
'камамбер',
|
||
'моцарелла',
|
||
'пармезан',
|
||
'гауда',
|
||
'овощи',
|
||
'ягоды',
|
||
'грибы',
|
||
'бакалея',
|
||
],
|
||
],
|
||
];
|
||
|
||
private const FOOD_GARBAGE_TERMS = [
|
||
'штукатурка',
|
||
'повар',
|
||
'кресло',
|
||
'кресло коляска',
|
||
'вешалка',
|
||
'фонарь',
|
||
'кондиционер',
|
||
'кулер',
|
||
'насаждения',
|
||
'купол',
|
||
];
|
||
|
||
private const TYPO_REPLACEMENTS = [
|
||
'ё' => 'е',
|
||
'киллограмм' => 'килограмм',
|
||
'килограм' => 'килограмм',
|
||
'креветкии' => 'креветки',
|
||
'криветки' => 'креветки',
|
||
'креветки' => 'креветки',
|
||
'косервы' => 'консервы',
|
||
'кансервы' => 'консервы',
|
||
'консерва' => 'консервы',
|
||
'консерв' => 'консервы',
|
||
'пресерв' => 'пресервы',
|
||
'пресерва' => 'пресервы',
|
||
'семга' => 'семга',
|
||
'сёмга' => 'семга',
|
||
'марож' => 'морож',
|
||
'свежемарож' => 'свежеморож',
|
||
'охложден' => 'охлажден',
|
||
'охлажден' => 'охлажден',
|
||
'разделаная' => 'разделанная',
|
||
'не разделанная' => 'неразделанная',
|
||
'неразделаная' => 'неразделанная',
|
||
'без головы' => 'безголовы',
|
||
'с головой' => 'сголовой',
|
||
'холодного копчения' => 'холодного копчения',
|
||
'горячего копчения' => 'горячего копчения',
|
||
];
|
||
|
||
/**
|
||
* @param array<int, array<string, mixed>> $products
|
||
* @return array<int, array<string, mixed>>
|
||
*/
|
||
public function rankProducts(string $query, array $products): array
|
||
{
|
||
$profile = $this->queryProfile($query);
|
||
|
||
if ($profile['tokens'] === []) {
|
||
return $products;
|
||
}
|
||
|
||
$ranked = [];
|
||
|
||
foreach ($products as $product) {
|
||
$score = $this->scoreProduct($profile, $product);
|
||
|
||
if ($score <= 0) {
|
||
continue;
|
||
}
|
||
|
||
$product['_search_score'] = $score;
|
||
$ranked[] = $product;
|
||
}
|
||
|
||
usort($ranked, function (array $left, array $right): int {
|
||
$leftUnavailable = $this->isPurchasableProduct($left) ? 0 : 1;
|
||
$rightUnavailable = $this->isPurchasableProduct($right) ? 0 : 1;
|
||
|
||
if ($leftUnavailable !== $rightUnavailable) {
|
||
return $leftUnavailable <=> $rightUnavailable;
|
||
}
|
||
|
||
$scoreCompare = ((int) ($right['_search_score'] ?? 0)) <=> ((int) ($left['_search_score'] ?? 0));
|
||
if ($scoreCompare !== 0) {
|
||
return $scoreCompare;
|
||
}
|
||
|
||
return [(int) ($left['sort_order'] ?? 0), (string) ($left['name'] ?? '')]
|
||
<=> [(int) ($right['sort_order'] ?? 0), (string) ($right['name'] ?? '')];
|
||
});
|
||
|
||
foreach ($ranked as &$product) {
|
||
unset($product['_search_score']);
|
||
}
|
||
unset($product);
|
||
|
||
return $ranked;
|
||
}
|
||
|
||
/**
|
||
* @return array{tokens: array<int, string>, stems: array<int, string>, expanded: array<int, string>, expanded_stems: array<int, string>, phrase: string, direct_tokens: array<int, string>, required_any: array<int, string>, required_all: array<int, string>, excluded_any: array<int, string>, strict_intents: array<int, string>}
|
||
*/
|
||
private function queryProfile(string $query): array
|
||
{
|
||
$phrase = $this->normalize($query);
|
||
$tokens = $this->tokens($phrase);
|
||
$expanded = $tokens;
|
||
$requiredAny = [];
|
||
$requiredAll = [];
|
||
$excludedAny = [];
|
||
$strictIntents = [];
|
||
$hasRedCaviarIntent = $this->matchesPhraseIntent($phrase, 'икра красной рыбы')
|
||
|| $this->matchesPhraseIntent($phrase, 'красная икра')
|
||
|| $this->matchesPhraseIntent($phrase, 'икра лососевая')
|
||
|| $this->matchesPhraseIntent($phrase, 'икра лососевых');
|
||
$specificCaviarTerms = $this->caviarIntentTerms($phrase);
|
||
$hasSpecificCaviarIntent = $specificCaviarTerms !== [];
|
||
$hasFishWithCaviarIntent = $this->isFishWithCaviarIntent($phrase);
|
||
$hasSpecificFishPhrase = (!$hasRedCaviarIntent && $this->matchesPhraseIntent($phrase, 'красная рыба'))
|
||
|| $this->matchesPhraseIntent($phrase, 'белая рыба')
|
||
|| $this->matchesPhraseIntent($phrase, 'филе белой рыбы');
|
||
|
||
foreach ($tokens as $token) {
|
||
foreach (self::TOKEN_SYNONYMS[$token] ?? [] as $synonym) {
|
||
$expanded[] = $this->normalizeToken($synonym);
|
||
}
|
||
|
||
$tokenStem = $this->stem($token);
|
||
foreach (self::TOKEN_REQUIRED_ANY as $sourceToken => $requiredTerms) {
|
||
if ($sourceToken === 'рыба' && $hasSpecificFishPhrase) {
|
||
continue;
|
||
}
|
||
|
||
if ($token !== $sourceToken && $tokenStem !== $this->stem($sourceToken)) {
|
||
continue;
|
||
}
|
||
|
||
foreach ($requiredTerms as $term) {
|
||
$requiredAny[] = $this->normalize($term);
|
||
}
|
||
}
|
||
|
||
foreach (self::TOKEN_SYNONYMS as $sourceToken => $synonyms) {
|
||
if ($token === $sourceToken || $tokenStem !== $this->stem($sourceToken)) {
|
||
continue;
|
||
}
|
||
|
||
foreach ($synonyms as $synonym) {
|
||
$expanded[] = $this->normalizeToken($synonym);
|
||
}
|
||
}
|
||
}
|
||
|
||
foreach (self::PHRASE_SYNONYMS as $needle => $synonyms) {
|
||
if ($this->matchesPhraseIntent($phrase, $needle)) {
|
||
foreach ($synonyms as $synonym) {
|
||
$expanded[] = $this->normalizeToken($synonym);
|
||
}
|
||
}
|
||
}
|
||
|
||
foreach (self::PHRASE_DIRECT_HINTS as $needle => $hints) {
|
||
if (($hasRedCaviarIntent || $hasSpecificCaviarIntent) && $needle === 'красная рыба') {
|
||
continue;
|
||
}
|
||
|
||
if ($this->matchesPhraseIntent($phrase, $needle)) {
|
||
foreach ($hints as $hint) {
|
||
$normalizedHint = $this->normalize($hint);
|
||
$expanded[] = $normalizedHint;
|
||
|
||
foreach ($this->tokens($normalizedHint, false) as $hintToken) {
|
||
$expanded[] = $hintToken;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
foreach (self::PHRASE_REQUIRED_ANY as $needle => $requiredTerms) {
|
||
if (($hasRedCaviarIntent || $hasSpecificCaviarIntent) && $needle === 'красная рыба') {
|
||
continue;
|
||
}
|
||
|
||
if ($this->matchesPhraseIntent($phrase, $needle)) {
|
||
foreach ($requiredTerms as $term) {
|
||
$requiredAny[] = $this->normalize($term);
|
||
}
|
||
}
|
||
}
|
||
|
||
foreach (self::PHRASE_REQUIRED_ALL as $needle => $requiredTerms) {
|
||
if (($hasRedCaviarIntent || $hasSpecificCaviarIntent) && $needle === 'красная рыба') {
|
||
continue;
|
||
}
|
||
|
||
if ($this->matchesPhraseIntent($phrase, $needle)) {
|
||
foreach ($requiredTerms as $term) {
|
||
$requiredAll[] = $this->normalize($term);
|
||
}
|
||
}
|
||
}
|
||
|
||
foreach (self::PHRASE_EXCLUDED_ANY as $needle => $excludedTerms) {
|
||
if (($hasRedCaviarIntent || $hasSpecificCaviarIntent) && $needle === 'красная рыба') {
|
||
continue;
|
||
}
|
||
|
||
if ($this->matchesPhraseIntent($phrase, $needle)) {
|
||
foreach ($excludedTerms as $term) {
|
||
$excludedAny[] = $this->normalize($term);
|
||
}
|
||
}
|
||
}
|
||
|
||
foreach (array_keys(self::STRICT_INTENTS) as $needle) {
|
||
if (($hasRedCaviarIntent || $hasSpecificCaviarIntent) && ($needle === 'красная рыба' || $needle === 'икра красной рыбы')) {
|
||
continue;
|
||
}
|
||
|
||
if ($this->matchesPhraseIntent($phrase, $needle)) {
|
||
$strictIntents[] = $needle;
|
||
}
|
||
}
|
||
|
||
if ($hasSpecificCaviarIntent) {
|
||
foreach ($specificCaviarTerms as $term) {
|
||
$requiredAny[] = $this->normalize($term);
|
||
$expanded[] = $this->normalize($term);
|
||
}
|
||
$requiredAll[] = $this->normalize('икра');
|
||
} elseif ($hasRedCaviarIntent) {
|
||
$strictIntents[] = 'икра красной рыбы';
|
||
}
|
||
|
||
if ($hasFishWithCaviarIntent) {
|
||
foreach ($this->fishWithCaviarTerms() as $term) {
|
||
$requiredAny[] = $this->normalize($term);
|
||
$expanded[] = $this->normalize($term);
|
||
}
|
||
$requiredAll[] = $this->normalize('рыба');
|
||
}
|
||
|
||
foreach ($tokens as $token) {
|
||
if (preg_match('/^(\d+(?:\.\d+)?)кг$/u', $token, $matches) === 1 && (float) $matches[1] >= 5) {
|
||
array_push($expanded, 'оптом', 'большая', 'фасовка', 'короб', 'мешок');
|
||
}
|
||
if (preg_match('/^(\d+(?:\.\d+)?)(г|кг|л|шт)$/u', $token) === 1) {
|
||
$expanded[] = 'фасовка';
|
||
}
|
||
}
|
||
|
||
$expanded = array_values(array_unique(array_filter($expanded)));
|
||
$requiredAny = array_values(array_unique(array_filter($requiredAny)));
|
||
$requiredAll = array_values(array_unique(array_filter($requiredAll)));
|
||
$excludedAny = array_values(array_unique(array_filter($excludedAny)));
|
||
|
||
return [
|
||
'tokens' => $tokens,
|
||
'direct_tokens' => $tokens,
|
||
'stems' => array_values(array_unique(array_map([$this, 'stem'], $tokens))),
|
||
'expanded' => $expanded,
|
||
'expanded_stems' => array_values(array_unique(array_map([$this, 'stem'], $expanded))),
|
||
'phrase' => $phrase,
|
||
'required_any' => $requiredAny,
|
||
'required_all' => $requiredAll,
|
||
'excluded_any' => $excludedAny,
|
||
'strict_intents' => array_values(array_unique($strictIntents)),
|
||
];
|
||
}
|
||
|
||
/**
|
||
* @param array<string, mixed> $profile
|
||
* @param array<string, mixed> $product
|
||
*/
|
||
private function scoreProduct(array $profile, array $product): int
|
||
{
|
||
$name = (string) ($product['name'] ?? '');
|
||
$category = (string) ($product['category_display_name'] ?? $product['category_name'] ?? '');
|
||
$seo = implode(' ', [
|
||
(string) ($product['h1'] ?? ''),
|
||
(string) ($product['seo_title'] ?? ''),
|
||
(string) ($product['seo_description'] ?? ''),
|
||
]);
|
||
$keywords = (string) ($product['seo_keywords'] ?? '');
|
||
|
||
$variantText = '';
|
||
foreach (($product['preview_variants'] ?? []) as $variant) {
|
||
$variantText .= ' ' . (string) ($variant['name'] ?? '');
|
||
$variantText .= ' ' . $this->quantityToken((float) ($variant['package_quantity'] ?? 0), (string) ($variant['unit'] ?? ''));
|
||
}
|
||
|
||
$nameText = $this->normalize($name);
|
||
$categoryText = $this->normalize($category);
|
||
$seoText = $this->normalize($seo);
|
||
$keywordText = $this->normalize($keywords);
|
||
$searchText = trim($nameText . ' ' . $categoryText . ' ' . $seoText . ' ' . $keywordText . ' ' . $this->normalize($variantText));
|
||
$domainText = trim($nameText . ' ' . $categoryText . ' ' . $keywordText);
|
||
|
||
if ($this->matchesExcludedAny(self::FOOD_GARBAGE_TERMS, $nameText)) {
|
||
return 0;
|
||
}
|
||
|
||
if (!$this->passesDomainGuards($profile['phrase'] ?? '', $nameText, $categoryText, $keywordText)) {
|
||
return 0;
|
||
}
|
||
|
||
if (!$this->passesStrictIntents($profile['strict_intents'] ?? [], $domainText)) {
|
||
return 0;
|
||
}
|
||
|
||
if (!$this->matchesRequiredAny($profile['required_any'] ?? [], $domainText)) {
|
||
return 0;
|
||
}
|
||
|
||
if (!$this->matchesRequiredAll($profile['required_all'] ?? [], $domainText)) {
|
||
return 0;
|
||
}
|
||
|
||
if ($this->matchesExcludedAny($profile['excluded_any'] ?? [], $domainText)) {
|
||
return 0;
|
||
}
|
||
|
||
$productTokens = $this->tokens($searchText, false);
|
||
$keywordTokens = $this->tokens($keywordText, false);
|
||
$productTokenLookup = array_flip($productTokens);
|
||
$keywordTokenLookup = array_flip($keywordTokens);
|
||
$productStemLookup = array_flip(array_map([$this, 'stem'], $productTokens));
|
||
$keywordStemLookup = array_flip(array_map([$this, 'stem'], $keywordTokens));
|
||
$score = 0;
|
||
$directHits = 0;
|
||
|
||
if ($profile['phrase'] !== '' && str_contains($searchText, $profile['phrase'])) {
|
||
$score += 120;
|
||
$directHits++;
|
||
}
|
||
|
||
foreach ($profile['strict_intents'] ?? [] as $intent) {
|
||
if (str_contains($categoryText, $this->normalize($intent))) {
|
||
$score += 80;
|
||
$directHits++;
|
||
continue;
|
||
}
|
||
|
||
if (str_contains($keywordText, $this->normalize($intent))) {
|
||
$score += 45;
|
||
$directHits++;
|
||
}
|
||
}
|
||
|
||
foreach ($profile['direct_tokens'] as $token) {
|
||
if (isset($productTokenLookup[$token])) {
|
||
$score += str_contains($nameText, $token) ? 34 : (str_contains($categoryText, $token) ? 28 : (isset($keywordTokenLookup[$token]) ? 30 : 18));
|
||
$directHits++;
|
||
continue;
|
||
}
|
||
|
||
$stem = $this->stem($token);
|
||
if (isset($productStemLookup[$stem])) {
|
||
$score += str_contains($nameText, $stem) ? 18 : (isset($keywordStemLookup[$stem]) ? 16 : 10);
|
||
$directHits++;
|
||
}
|
||
}
|
||
|
||
foreach ($profile['expanded'] as $token) {
|
||
if (isset($productTokenLookup[$token])) {
|
||
$score += str_contains($categoryText, $token) ? 10 : (isset($keywordTokenLookup[$token]) ? 12 : 6);
|
||
continue;
|
||
}
|
||
|
||
$stem = $this->stem($token);
|
||
if (isset($productStemLookup[$stem])) {
|
||
$score += isset($keywordStemLookup[$stem]) ? 8 : 4;
|
||
}
|
||
}
|
||
|
||
foreach ($profile['tokens'] as $token) {
|
||
if (preg_match('/^\d+(?:\.\d+)?(кг|г|л|шт)$/u', $token) === 1 && isset($productTokenLookup[$token])) {
|
||
$score += 35;
|
||
$directHits++;
|
||
}
|
||
}
|
||
|
||
if ($directHits === 0 && !$this->allowsAssociativeOnly($profile['tokens'])) {
|
||
return 0;
|
||
}
|
||
|
||
$salesCount = min(20, (int) ($product['sales_count'] ?? 0));
|
||
|
||
return $score + $salesCount;
|
||
}
|
||
|
||
private function passesDomainGuards(string $phrase, string $nameText, string $categoryText, string $keywordText): bool
|
||
{
|
||
$domainText = trim($nameText . ' ' . $categoryText . ' ' . $keywordText);
|
||
if ($phrase === '' || $domainText === '') {
|
||
return true;
|
||
}
|
||
|
||
if ($this->isFishWithCaviarIntent($phrase)) {
|
||
return $this->matchesAnyDomainTerm($this->fishWithCaviarTerms(), $domainText)
|
||
&& $this->matchesAnyDomainTerm(['рыба', 'рыбная', 'рыбные', 'корюшка', 'вобла', 'камбала', 'лещ', 'щука'], $domainText)
|
||
&& !$this->matchesAnyDomainTerm($this->fishWithCaviarExclusions(), $domainText);
|
||
}
|
||
|
||
$specificCaviarTerms = $this->caviarIntentTerms($phrase);
|
||
if ($specificCaviarTerms !== []) {
|
||
return $this->matchesAnyDomainTerm($specificCaviarTerms, $domainText)
|
||
&& $this->matchesAnyDomainTerm(['икра', 'икорная', 'масаго', 'тобико'], $domainText)
|
||
&& !$this->matchesAnyDomainTerm($this->caviarProductExclusions(), $domainText);
|
||
}
|
||
|
||
$isRedCaviarQuery = $this->matchesAnyDomainTerm([
|
||
'икра красной рыбы',
|
||
'красная икра',
|
||
'икра лососевая',
|
||
'икра лососевых',
|
||
], $phrase);
|
||
|
||
if ($isRedCaviarQuery) {
|
||
return $this->matchesAnyDomainTerm([
|
||
'красная икра',
|
||
'икра горбуши',
|
||
'икра кеты',
|
||
'икра нерки',
|
||
'икра кижуча',
|
||
'икра форели',
|
||
'икра лососевых',
|
||
], $domainText)
|
||
&& !$this->matchesAnyDomainTerm([
|
||
'икра минтая',
|
||
'икра палтуса',
|
||
'икра трески',
|
||
'икра щуки',
|
||
'икра сельди',
|
||
'масаго',
|
||
'тобико',
|
||
'имитированная',
|
||
'неразделанная',
|
||
'разделанная',
|
||
'потрошеная',
|
||
'тушка',
|
||
'стейки',
|
||
'филе',
|
||
'брюшки',
|
||
'балык',
|
||
'хребты',
|
||
], $domainText);
|
||
}
|
||
|
||
$isCaviarQuery = $this->matchesAnyDomainTerm(['икра', 'икорная'], $phrase);
|
||
if ($isCaviarQuery) {
|
||
return $this->matchesAnyDomainTerm(['икра', 'масаго', 'тобико'], $domainText)
|
||
&& !$this->matchesAnyDomainTerm([
|
||
'рыбное филе',
|
||
'рыбные стейки',
|
||
'неразделанная',
|
||
'разделанная',
|
||
'потрошеная',
|
||
'тушка',
|
||
'мясо',
|
||
'сыры',
|
||
'молочная продукция',
|
||
'бакалея',
|
||
'овощи',
|
||
'ягоды',
|
||
], $domainText);
|
||
}
|
||
|
||
$isSeafoodQuery = $this->matchesAnyDomainTerm([
|
||
'морепродукты',
|
||
'креветки',
|
||
'креветка',
|
||
'кальмар',
|
||
'краб',
|
||
'мидии',
|
||
'морской гребешок',
|
||
'осьминог',
|
||
'лангустины',
|
||
'вонголе',
|
||
'раки',
|
||
'чука',
|
||
'угорь',
|
||
], $phrase);
|
||
|
||
if ($isSeafoodQuery) {
|
||
return $this->matchesAnyDomainTerm([
|
||
'морепродукты',
|
||
'креветки',
|
||
'креветка',
|
||
'кальмар',
|
||
'краб',
|
||
'мидии',
|
||
'морской гребешок',
|
||
'осьминог',
|
||
'лангустины',
|
||
'вонголе',
|
||
'раки',
|
||
'чука',
|
||
'угорь',
|
||
], $domainText)
|
||
&& !$this->matchesAnyDomainTerm(['говядина', 'свинина', 'баранина', 'курица', 'индейка', 'сыры'], $domainText);
|
||
}
|
||
|
||
$isMeatQuery = $this->matchesAnyDomainTerm([
|
||
'мясо',
|
||
'говядина',
|
||
'свинина',
|
||
'баранина',
|
||
'курица',
|
||
'индейка',
|
||
'телятина',
|
||
'утка',
|
||
'гусь',
|
||
'кролик',
|
||
'буйвол',
|
||
], $phrase);
|
||
|
||
if ($isMeatQuery && !$this->matchesAnyDomainTerm(['краб', 'кальмар', 'гребешок', 'морепродукты'], $phrase)) {
|
||
return $this->matchesAnyDomainTerm([
|
||
'мясо',
|
||
'говядина',
|
||
'свинина',
|
||
'баранина',
|
||
'курица',
|
||
'индейка',
|
||
'телятина',
|
||
'утка',
|
||
'гусь',
|
||
'кролик',
|
||
'буйвол',
|
||
'субпродукты',
|
||
'мясная гастрономия',
|
||
], $domainText)
|
||
&& !$this->matchesAnyDomainTerm(['рыба', 'морепродукты', 'икра', 'сыры', 'бакалея'], $domainText);
|
||
}
|
||
|
||
$isCheeseQuery = $this->matchesAnyDomainTerm([
|
||
'сыр',
|
||
'сыры',
|
||
'бри',
|
||
'камамбер',
|
||
'моцарелла',
|
||
'сулугуни',
|
||
'пармезан',
|
||
'гауда',
|
||
'чеддер',
|
||
'горгонзола',
|
||
], $phrase);
|
||
|
||
if ($isCheeseQuery && !$this->matchesAnyDomainTerm(['сырные палочки', 'сырники', 'кордон блю'], $phrase)) {
|
||
return $this->matchesAnyDomainTerm([
|
||
'сыр',
|
||
'сыры',
|
||
'молочная продукция',
|
||
'бри',
|
||
'камамбер',
|
||
'моцарелла',
|
||
'сулугуни',
|
||
'пармезан',
|
||
'гауда',
|
||
'чеддер',
|
||
'горгонзола',
|
||
], $domainText)
|
||
&& !$this->matchesAnyDomainTerm(['рыба', 'морепродукты', 'икра', 'мясо', 'овощи', 'ягоды', 'бакалея'], $domainText);
|
||
}
|
||
|
||
$isGroceryQuery = $this->matchesAnyDomainTerm([
|
||
'бакалея',
|
||
'соус',
|
||
'крупа',
|
||
'мука',
|
||
'масло растительное',
|
||
'макаронные изделия',
|
||
'специи',
|
||
'приправы',
|
||
'соль',
|
||
'сахар',
|
||
], $phrase);
|
||
|
||
if ($isGroceryQuery) {
|
||
return $this->matchesAnyDomainTerm([
|
||
'бакалея',
|
||
'соус',
|
||
'крупа',
|
||
'мука',
|
||
'масло растительное',
|
||
'макаронные изделия',
|
||
'специи',
|
||
'приправы',
|
||
'соль',
|
||
'сахар',
|
||
], $domainText)
|
||
&& !$this->matchesAnyDomainTerm(['рыба', 'морепродукты', 'икра', 'мясо', 'сыры'], $domainText);
|
||
}
|
||
|
||
$isProduceQuery = $this->matchesAnyDomainTerm(['овощи', 'ягоды', 'грибы', 'фрукты'], $phrase);
|
||
if ($isProduceQuery) {
|
||
return $this->matchesAnyDomainTerm(['овощи', 'ягоды', 'грибы', 'фрукты', 'смесь'], $domainText)
|
||
&& !$this->matchesAnyDomainTerm(['рыба', 'морепродукты', 'икра', 'мясо', 'сыры', 'бакалея'], $domainText);
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
private function isFishWithCaviarIntent(string $phrase): bool
|
||
{
|
||
return $this->matchesAnyDomainTerm([
|
||
'рыба с икрой',
|
||
'с икрой',
|
||
'икряная рыба',
|
||
'икряная',
|
||
'икряной',
|
||
'икряный',
|
||
'икряные',
|
||
], $phrase)
|
||
&& !$this->matchesAnyDomainTerm([
|
||
'икра красной рыбы',
|
||
'красная икра',
|
||
'икра щуки',
|
||
'икра палтуса',
|
||
'икра минтая',
|
||
'икра трески',
|
||
'икра сельди',
|
||
'масаго',
|
||
'тобико',
|
||
], $phrase);
|
||
}
|
||
|
||
/**
|
||
* @return array<int, string>
|
||
*/
|
||
private function fishWithCaviarTerms(): array
|
||
{
|
||
return [
|
||
'с икрой',
|
||
'икряная',
|
||
'икряной',
|
||
'икряный',
|
||
'икряные',
|
||
'икряная рыба',
|
||
'рыба с икрой',
|
||
];
|
||
}
|
||
|
||
/**
|
||
* @return array<int, string>
|
||
*/
|
||
private function fishWithCaviarExclusions(): array
|
||
{
|
||
return [
|
||
'икра красной рыбы',
|
||
'красная икра',
|
||
'икра горбуши',
|
||
'икра кеты',
|
||
'икра нерки',
|
||
'икра кижуча',
|
||
'икра форели',
|
||
'икра щуки',
|
||
'икра палтуса',
|
||
'икра минтая',
|
||
'икра трески',
|
||
'икра сельди',
|
||
'масаго',
|
||
'тобико',
|
||
'имитированная',
|
||
'филе',
|
||
'стейки',
|
||
'балык',
|
||
'хребты',
|
||
'копченая',
|
||
'копчения',
|
||
'горячего копчения',
|
||
'холодного копчения',
|
||
'вяленая',
|
||
'слабосоленая',
|
||
'малосольная',
|
||
'пресервы',
|
||
'консервы',
|
||
'мясо',
|
||
'сыры',
|
||
'морепродукты',
|
||
'бакалея',
|
||
'овощи',
|
||
'ягоды',
|
||
];
|
||
}
|
||
|
||
/**
|
||
* Возвращает только те виды икры, которые покупатель явно назвал в запросе.
|
||
*
|
||
* @return array<int, string>
|
||
*/
|
||
private function caviarIntentTerms(string $phrase): array
|
||
{
|
||
$hasCaviarSignal = $this->matchesAnyDomainTerm([
|
||
'икра',
|
||
'икры',
|
||
'икру',
|
||
'икорная',
|
||
'масаго',
|
||
'тобико',
|
||
'щучья',
|
||
], $phrase);
|
||
|
||
if (!$hasCaviarSignal) {
|
||
return [];
|
||
}
|
||
|
||
$groups = [];
|
||
|
||
if ($this->matchesAnyDomainTerm([
|
||
'красная икра',
|
||
'икра красной рыбы',
|
||
'икра лососевая',
|
||
'икра лососевых',
|
||
'красная',
|
||
'лососевая',
|
||
'лососевых',
|
||
'горбуши',
|
||
'кеты',
|
||
'нерки',
|
||
'кижуча',
|
||
'форели',
|
||
], $phrase)) {
|
||
array_push(
|
||
$groups,
|
||
'красная икра',
|
||
'икра красной рыбы',
|
||
'икра лососевых',
|
||
'икра горбуши',
|
||
'икра кеты',
|
||
'икра нерки',
|
||
'икра кижуча',
|
||
'икра форели'
|
||
);
|
||
}
|
||
|
||
if ($this->matchesAnyDomainTerm(['щучья', 'щуки', 'икра щуки'], $phrase)) {
|
||
array_push($groups, 'икра щуки', 'щучья икра');
|
||
}
|
||
|
||
if ($this->matchesAnyDomainTerm(['палтус', 'палтуса', 'икра палтуса'], $phrase)) {
|
||
array_push($groups, 'икра палтуса');
|
||
}
|
||
|
||
if ($this->matchesAnyDomainTerm(['минтай', 'минтая', 'икра минтая'], $phrase)) {
|
||
array_push($groups, 'икра минтая');
|
||
}
|
||
|
||
if ($this->matchesAnyDomainTerm(['треска', 'трески', 'икра трески'], $phrase)) {
|
||
array_push($groups, 'икра трески');
|
||
}
|
||
|
||
if ($this->matchesAnyDomainTerm(['сельдь', 'сельди', 'икра сельди'], $phrase)) {
|
||
array_push($groups, 'икра сельди');
|
||
}
|
||
|
||
if ($this->matchesAnyDomainTerm(['масаго'], $phrase)) {
|
||
$groups[] = 'масаго';
|
||
}
|
||
|
||
if ($this->matchesAnyDomainTerm(['тобико'], $phrase)) {
|
||
$groups[] = 'тобико';
|
||
}
|
||
|
||
return array_values(array_unique($groups));
|
||
}
|
||
|
||
/**
|
||
* @return array<int, string>
|
||
*/
|
||
private function caviarProductExclusions(): array
|
||
{
|
||
return [
|
||
'рыбное филе',
|
||
'рыбные стейки',
|
||
'неразделанная',
|
||
'разделанная',
|
||
'потрошеная',
|
||
'тушка',
|
||
'стейки',
|
||
'филе',
|
||
'брюшки',
|
||
'балык',
|
||
'хребты',
|
||
'мясо',
|
||
'сыры',
|
||
'молочная продукция',
|
||
'бакалея',
|
||
'овощи',
|
||
'ягоды',
|
||
];
|
||
}
|
||
|
||
/**
|
||
* @param array<int, string> $requiredAny
|
||
*/
|
||
private function matchesAnyDomainTerm(array $terms, string $searchText): bool
|
||
{
|
||
$searchText = $this->normalize($searchText);
|
||
$searchStems = array_flip(array_map([$this, 'stem'], $this->tokens($searchText, false)));
|
||
|
||
foreach ($terms as $term) {
|
||
if ($this->matchesDomainTerm($this->normalize((string) $term), $searchText, $searchStems)) {
|
||
return true;
|
||
}
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* @param array<int, string> $requiredAny
|
||
*/
|
||
private function matchesRequiredAny(array $requiredAny, string $searchText): bool
|
||
{
|
||
if ($requiredAny === []) {
|
||
return true;
|
||
}
|
||
|
||
$searchStems = array_flip(array_map([$this, 'stem'], $this->tokens($searchText, false)));
|
||
|
||
foreach ($requiredAny as $term) {
|
||
if ($this->matchesDomainTerm($term, $searchText, $searchStems)) {
|
||
return true;
|
||
}
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* @param array<int, string> $strictIntents
|
||
*/
|
||
private function passesStrictIntents(array $strictIntents, string $searchText): bool
|
||
{
|
||
foreach ($strictIntents as $intent) {
|
||
$rules = self::STRICT_INTENTS[$intent] ?? null;
|
||
if (!is_array($rules)) {
|
||
continue;
|
||
}
|
||
|
||
if (!$this->matchesRequiredAny($rules['required_any'] ?? [], $searchText)) {
|
||
return false;
|
||
}
|
||
|
||
if (!$this->matchesRequiredAll($rules['required_all'] ?? [], $searchText)) {
|
||
return false;
|
||
}
|
||
|
||
if ($this->matchesExcludedAny($rules['excluded_any'] ?? [], $searchText)) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* @param array<int, string> $requiredAll
|
||
*/
|
||
private function matchesRequiredAll(array $requiredAll, string $searchText): bool
|
||
{
|
||
foreach ($requiredAll as $term) {
|
||
if (!$this->matchesRequiredAny([$term], $searchText)) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* @param array<int, string> $excludedAny
|
||
*/
|
||
private function matchesExcludedAny(array $excludedAny, string $searchText): bool
|
||
{
|
||
if ($excludedAny === []) {
|
||
return false;
|
||
}
|
||
|
||
$searchStems = array_flip(array_map([$this, 'stem'], $this->tokens($searchText, false)));
|
||
|
||
foreach ($excludedAny as $term) {
|
||
if ($this->matchesDomainTerm($term, $searchText, $searchStems)) {
|
||
return true;
|
||
}
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* @param array<string, int> $searchStems
|
||
*/
|
||
private function matchesDomainTerm(string $term, string $searchText, array $searchStems): bool
|
||
{
|
||
$term = trim($term);
|
||
if ($term === '') {
|
||
return false;
|
||
}
|
||
|
||
$termTokens = $this->tokens($term, false);
|
||
if ($termTokens === []) {
|
||
return false;
|
||
}
|
||
|
||
if (count($termTokens) > 1 && str_contains($searchText, $term)) {
|
||
return true;
|
||
}
|
||
|
||
$termStems = array_values(array_unique(array_map([$this, 'stem'], $termTokens)));
|
||
|
||
return array_diff($termStems, array_keys($searchStems)) === [];
|
||
}
|
||
|
||
/**
|
||
* @param array<string, mixed> $product
|
||
*/
|
||
private function isPurchasableProduct(array $product): bool
|
||
{
|
||
$variants = $product['preview_variants'] ?? [];
|
||
$activeVariant = is_array($variants) && isset($variants[0]) && is_array($variants[0])
|
||
? $variants[0]
|
||
: [];
|
||
|
||
return (int) ($product['is_published'] ?? 1) === 1
|
||
&& (int) ($product['is_available'] ?? 1) === 1
|
||
&& (float) ($activeVariant['package_price'] ?? $product['package_price'] ?? 0) > 0;
|
||
}
|
||
|
||
private function matchesPhraseIntent(string $phrase, string $needle): bool
|
||
{
|
||
$needle = $this->normalize($needle);
|
||
if ($needle !== '' && str_contains($phrase, $needle)) {
|
||
return true;
|
||
}
|
||
|
||
$phraseStems = array_values(array_unique(array_map([$this, 'stem'], $this->tokens($phrase))));
|
||
$needleStems = array_values(array_unique(array_map([$this, 'stem'], $this->tokens($needle))));
|
||
|
||
return $needleStems !== [] && array_diff($needleStems, $phraseStems) === [];
|
||
}
|
||
|
||
/**
|
||
* @param array<int, string> $tokens
|
||
*/
|
||
private function allowsAssociativeOnly(array $tokens): bool
|
||
{
|
||
$broadTokens = ['ужин', 'дача', 'мангал', 'гриль', 'пикник', 'оптом', 'розница', 'закуска'];
|
||
|
||
return array_intersect($tokens, $broadTokens) !== [];
|
||
}
|
||
|
||
private function normalize(string $text): string
|
||
{
|
||
$text = $this->lower($text);
|
||
$text = str_replace(array_keys(self::TYPO_REPLACEMENTS), array_values(self::TYPO_REPLACEMENTS), $text);
|
||
$text = str_replace(',', '.', $text);
|
||
$text = preg_replace('/(\d+(?:\.\d+)?)\s*(килограмм(?:а|ов)?|кг|kg)\.?/u', '$1кг', $text) ?? $text;
|
||
$text = preg_replace('/(\d+(?:\.\d+)?)\s*(грамм(?:а|ов)?|гр|г)\.?/u', '$1г', $text) ?? $text;
|
||
$text = preg_replace('/(\d+(?:\.\d+)?)\s*(литр(?:а|ов)?|л|l)\.?/u', '$1л', $text) ?? $text;
|
||
$text = preg_replace('/(\d+(?:\.\d+)?)\s*(штук|штуки|шт)\.?/u', '$1шт', $text) ?? $text;
|
||
$text = preg_replace('/[^a-zа-я0-9\.]+/u', ' ', $text) ?? $text;
|
||
|
||
return trim(preg_replace('/\s+/u', ' ', $text) ?? $text);
|
||
}
|
||
|
||
/**
|
||
* @return array<int, string>
|
||
*/
|
||
private function tokens(string $text, bool $removeStops = true): array
|
||
{
|
||
$parts = preg_split('/\s+/u', $text, -1, PREG_SPLIT_NO_EMPTY) ?: [];
|
||
$tokens = [];
|
||
|
||
foreach ($parts as $part) {
|
||
$token = $this->normalizeToken((string) $part);
|
||
if ($token === '' || ($removeStops && in_array($token, self::STOP_WORDS, true))) {
|
||
continue;
|
||
}
|
||
$tokens[] = $token;
|
||
}
|
||
|
||
return array_values(array_unique($tokens));
|
||
}
|
||
|
||
private function normalizeToken(string $token): string
|
||
{
|
||
return trim($this->normalize($token));
|
||
}
|
||
|
||
private function stem(string $token): string
|
||
{
|
||
if ($token === '' || preg_match('/^\d/u', $token) === 1) {
|
||
return $token;
|
||
}
|
||
|
||
$suffixes = [
|
||
'иями', 'ями', 'ами', 'ого', 'его', 'ому', 'ему', 'ыми', 'ими', 'ая', 'яя', 'ое', 'ее',
|
||
'ые', 'ие', 'ый', 'ий', 'ой', 'ую', 'юю', 'ом', 'ем', 'ах', 'ях', 'ам', 'ям', 'ов',
|
||
'ев', 'ей', 'ою', 'ею', 'а', 'я', 'ы', 'и', 'е', 'у', 'ю', 'ь',
|
||
];
|
||
|
||
foreach ($suffixes as $suffix) {
|
||
if ($this->length($token) > $this->length($suffix) + 3 && str_ends_with($token, $suffix)) {
|
||
return function_exists('mb_substr')
|
||
? mb_substr($token, 0, -$this->length($suffix), 'UTF-8')
|
||
: substr($token, 0, -strlen($suffix));
|
||
}
|
||
}
|
||
|
||
return $token;
|
||
}
|
||
|
||
private function quantityToken(float $quantity, string $unit): string
|
||
{
|
||
if ($quantity <= 0) {
|
||
return '';
|
||
}
|
||
|
||
$unit = $this->normalizeToken($unit);
|
||
$unit = match ($unit) {
|
||
'kg' => 'кг',
|
||
default => $unit,
|
||
};
|
||
|
||
$value = rtrim(rtrim(number_format($quantity, 3, '.', ''), '0'), '.');
|
||
|
||
return $value . $unit;
|
||
}
|
||
|
||
private function lower(string $value): string
|
||
{
|
||
return function_exists('mb_strtolower') ? mb_strtolower($value, 'UTF-8') : strtolower($value);
|
||
}
|
||
|
||
private function length(string $value): int
|
||
{
|
||
return function_exists('mb_strlen') ? mb_strlen($value, 'UTF-8') : strlen($value);
|
||
}
|
||
}
|