['красная', 'рыба', 'лососевые', 'дикая', 'засолка', 'жарка', 'целая'], 'кета' => ['красная', 'рыба', 'лососевые', 'дикая', 'засолка', 'жарка'], 'кижуч' => ['красная', 'рыба', 'лососевые', 'дикая', 'засолка'], 'нерка' => ['красная', 'рыба', 'лососевые', 'дикая', 'премиум'], 'семга' => ['лосось', 'красная', 'рыба', 'лососевые', 'стейк', 'филе', 'слабосоленая'], 'лосось' => ['семга', 'красная', 'рыба', 'лососевые', 'стейк', 'филе'], 'форель' => ['красная', 'рыба', 'лососевые', 'стейк', 'филе', 'засолка'], 'чавыча' => ['красная', 'рыба', 'лососевые', 'дикая'], 'голец' => ['красная', 'рыба', 'лососевые', 'дикая'], 'неразделанная' => ['целая', 'тушка', 'сголовой', 'засолка', 'жарка'], 'целая' => ['неразделанная', 'тушка', 'сголовой'], 'потрошеная' => ['разделанная', 'безголовы', 'пбг', 'тушка'], 'разделанная' => ['потрошеная', 'безголовы', 'пбг', 'тушка'], 'пбг' => ['потрошеная', 'разделанная', 'безголовы'], 'филе' => ['безкостей', 'пласт', 'кубик', 'лойн'], 'дори' => ['пангасиус', 'белая', 'рыба', 'рыбное', 'филе', 'аквакультура'], 'пангасиус' => ['дори', 'белая', 'рыба', 'рыбное', 'филе', 'аквакультура'], 'стейк' => ['стейки', 'нарезка', 'порционно', 'гриль', 'жарка'], 'стейки' => ['стейк', 'нарезка', 'порционно', 'гриль', 'жарка'], 'фарш' => ['котлеты', 'полуфабрикаты'], 'копченая' => ['копчение', 'горячего', 'холодного', 'закуска'], 'копчение' => ['копченая', 'горячего', 'холодного', 'закуска'], 'слабосоленая' => ['малосольная', 'посол', 'закуска'], 'малосольная' => ['слабосоленая', 'посол', 'закуска'], 'вяленая' => ['сушеная', 'закуска', 'пиво'], 'сушеная' => ['вяленая', 'закуска', 'пиво'], 'икра' => ['деликатес', 'красная', 'банка', 'праздник'], 'консервы' => ['пресервы', 'банка', 'жб', 'рыба'], 'пресервы' => ['консервы', 'банка', 'рыба', 'слабосоленая'], 'креветки' => ['креветка', 'морепродукты', 'лангустины', 'северные', 'тигровые', 'ванамей'], 'креветка' => ['креветки', 'морепродукты', 'лангустины', 'северные', 'тигровые', 'ванамей'], 'мидии' => ['морепродукты', 'раковина', 'киви'], 'кальмар' => ['морепродукты', 'кольца', 'тушка', 'щупальца'], 'краб' => ['морепродукты', 'фаланга', 'клешни', 'мясо'], 'мясо' => ['говядина', 'свинина', 'баранина', 'курица', 'индейка', 'гриль', 'шашлык'], 'сыр' => ['сыры', 'молочная', 'плавленый', 'твердый'], 'сыры' => ['сыр', 'молочная', 'плавленый', 'твердый'], 'овощи' => ['смесь', 'заморозка', 'гарнир'], 'ягоды' => ['заморозка', 'компот', 'десерт'], 'грибы' => ['заморозка', 'маринованные'], 'бакалея' => ['соус', 'крупа', 'масло', 'специи', 'приправы'], 'оптом' => ['большая', 'фасовка', 'короб', 'мешок', 'монолитная'], 'розница' => ['маленькая', 'фасовка', '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> $products * @return array> */ 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, stems: array, expanded: array, expanded_stems: array, phrase: string, direct_tokens: array, required_any: array, required_all: array, excluded_any: array, strict_intents: array} */ 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 $profile * @param array $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 */ private function fishWithCaviarTerms(): array { return [ 'с икрой', 'икряная', 'икряной', 'икряный', 'икряные', 'икряная рыба', 'рыба с икрой', ]; } /** * @return array */ private function fishWithCaviarExclusions(): array { return [ 'икра красной рыбы', 'красная икра', 'икра горбуши', 'икра кеты', 'икра нерки', 'икра кижуча', 'икра форели', 'икра щуки', 'икра палтуса', 'икра минтая', 'икра трески', 'икра сельди', 'масаго', 'тобико', 'имитированная', 'филе', 'стейки', 'балык', 'хребты', 'копченая', 'копчения', 'горячего копчения', 'холодного копчения', 'вяленая', 'слабосоленая', 'малосольная', 'пресервы', 'консервы', 'мясо', 'сыры', 'морепродукты', 'бакалея', 'овощи', 'ягоды', ]; } /** * Возвращает только те виды икры, которые покупатель явно назвал в запросе. * * @return array */ 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 */ private function caviarProductExclusions(): array { return [ 'рыбное филе', 'рыбные стейки', 'неразделанная', 'разделанная', 'потрошеная', 'тушка', 'стейки', 'филе', 'брюшки', 'балык', 'хребты', 'мясо', 'сыры', 'молочная продукция', 'бакалея', 'овощи', 'ягоды', ]; } /** * @param array $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 $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 $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 $requiredAll */ private function matchesRequiredAll(array $requiredAll, string $searchText): bool { foreach ($requiredAll as $term) { if (!$this->matchesRequiredAny([$term], $searchText)) { return false; } } return true; } /** * @param array $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 $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 $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 $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 */ 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); } }