UNPKG

umbot

Version:

Мультиплатформенный фреймворк для создания голосовых навыков и чат-ботов с единой бизнес-логикой. Встроенная поддержка ВКонтакте, Telegram, Viber, MAX, Яндекс Алисы, Маруси и Сбера SmartApp. Архитектура на адаптерах позволяет подключать любые другие платф

454 lines (453 loc) 18.4 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.Text = void 0; /** * Модуль для работы с текстом * * Предоставляет набор утилит для: * - Обработки и форматирования текста * - Поиска совпадений в тексте * - Проверки схожести текстов * - Работы с окончаниями слов */ const RegExp_1 = require("./RegExp"); const util_1 = require("./util"); const os_1 = __importDefault(require("os")); let MAX_CACHE_SIZE = 3000; function setMemoryLimit() { const total = os_1.default.totalmem(); // На всякий случай ограничиваем кэш, если в кэш будут класть группы if (total < 0.8 * 1024 ** 3) { MAX_CACHE_SIZE = 2000; } else if (total < 3 * 1024 ** 3) { MAX_CACHE_SIZE = 2500; } else { MAX_CACHE_SIZE = 3000; } } setMemoryLimit(); const CONFIRM_PATTERNS = /(?:^|\s)да(?:^|\s|$)|(?:^|\s)конечно(?:^|\s|$)|(?:^|\s)соглас[^s]+(?:^|\s|$)|(?:^|\s)подтвер[^s]+(?:^|\s|$)/imu; const REJECT_PATTERNS = /(?:^|\s)нет(?:^|\s|$)|(?:^|\s)неа(?:^|\s|$)|(?:^|\s)не(?:^|\s|$)/imu; /** * Класс для работы с текстом и текстовыми операциями * * @remarks * Класс предоставляет статические методы для: * - Обрезки текста * - Проверки URL * - Определения согласия/отрицания * - Поиска совпадений * - Работы с окончаниями слов * - Проверки схожести текстов * * @example * ```ts * // Обрезка текста * Text.resize('Длинный текст', 5); // -> 'Длин...' * * // Проверка URL * Text.isUrl('http://localhost'); // -> true * * // Определение согласия * Text.isSayTrue('да, согласен'); // -> true * * // Поиск совпадений * Text.isSayText(['привет', 'здравствуйте'], 'привет мир'); // -> true * * // Работа с окончаниями * Text.getEnding(5, ['яблоко', 'яблока', 'яблок']); // -> 'яблок' * * // Проверка схожести * Text.textSimilarity('привет', 'привт', 80); // -> { status: true, percent: 90, ... } * ``` */ class Text { /** * Кэш для скомпилированных регулярных выражений. * Улучшает производительность при повторном использовании шаблонов */ static #regexCache = new Map(); /** * Обрезает текст до указанной длины * * @param {string | null} text - Исходный текст * @param {number} [size=950] - Максимальная длина результата * @param {boolean} [isEllipsis=true] - Добавлять ли многоточие в конце * @returns {string} Обрезанный текст * * @example * ```ts * Text.resize('Длинный текст', 6); // -> 'Дли...' * Text.resize('Длинный текст', 6, false); // -> 'Длинны' * ``` */ static resize(text, size = 950, isEllipsis = true) { if (!text) { return ''; } if (text.length <= size) { return text; } if (!isEllipsis) { return text.substring(0, size); } const ellipsisSize = Math.max(0, size - 3); return text.substring(0, ellipsisSize) + '...'; } /** * Проверяет, является ли строка URL-адресом * * @param {string} link - Проверяемая строка * @returns {boolean} true, если строка является URL-адресом * * @example * ```ts * Text.isUrl('http://localhost'); // -> true * Text.isUrl('не url'); // -> false * ``` */ static isUrl(link) { if (link.startsWith('http://') || link.startsWith('https://')) { try { new URL(link); return true; } catch { return false; } } return false; } /** * Определяет наличие в тексте согласия пользователя * * @param {string} text - Проверяемый текст * @returns {boolean} true, если найдено подтверждение * * @remarks * Распознает следующие паттерны: * - "да" * - "конечно" * - "согласен"/"согласна" * - "подтверждаю"/"подтверждаю" * * @example * ```ts * Text.isSayTrue('да, согласен'); // -> true * Text.isSayTrue('нет, не согласен'); // -> false * ``` */ static isSayTrue(text) { if (!text) { return false; } return Text.#isSayPattern(CONFIRM_PATTERNS, text, true); } /** * Определяет наличие в тексте отрицания пользователя * * @param {string} text - Проверяемый текст * @returns {boolean} true, если найдено отрицание * * @remarks * Распознает следующие паттерны: * - "нет" * - "неа" * - "не" * * @example * ```ts * Text.isSayFalse('нет, не хочу'); // -> true * Text.isSayFalse('да, хочу'); // -> false * ``` */ static isSayFalse(text) { if (!text) { return false; } return Text.#isSayPattern(REJECT_PATTERNS, text, true); } /** * Проверяет наличие совпадений в тексте по шаблонам * * @param {TPattern} patterns - Шаблоны для поиска * @param {string} text - Проверяемый текст * @param {boolean} useDirectRegExp - Использовать исходные RegExp напрямую без нормализации и кэширования * @param {RegExpConstructor} customReg - Произвольный обработчик для регулярных выражений * @returns {boolean} true, если найдено совпадение с одним из шаблонов */ static #isSayPattern(patterns, text, useDirectRegExp = false, customReg = undefined) { if (!text) { return false; } let pattern; if (Array.isArray(patterns)) { const newPatterns = []; for (let i = 0; i < patterns.length; i++) { const patternBase = patterns[i]; if ((0, RegExp_1.isRegex)(patternBase)) { const cachedRegex = useDirectRegExp ? patternBase : Text.#getCachedRegex(patternBase, customReg); if (cachedRegex.global) { // На случай если кто-то задал флаг g, сбрасываем lastIndex, // так как это может привести к некорректному результату cachedRegex.lastIndex = 0; } const res = cachedRegex.test(text); if (res) { return res; } } else { newPatterns.push(patternBase); } } if (newPatterns.length) { pattern = `(${newPatterns.join(')|(')})`; newPatterns.length = 0; } else { return false; } } else { pattern = patterns; } const cachedRegex = useDirectRegExp && (0, RegExp_1.isRegex)(pattern) ? pattern : Text.#getCachedRegex(pattern, customReg); return cachedRegex.test(text); } /** * Проверяет наличие совпадений в тексте * * @param {TPatternReg} find - Искомый текст или массив текстов * @param {string} text - Исходный текст для поиска * @param {boolean} [isPattern=false] - Использовать ли регулярные выражения * @param {boolean} [useDirectRegExp=false] - Использовать исходные RegExp напрямую без нормализации и кэширования. Стоит использовать только в крайних случаях. * @param {RegExpConstructor} [customReg=undefined] - Произвольная реализация для обработки регулярных выражений * @returns {boolean} true, если найдено совпадение * * @example * ```ts * // Поиск подстроки * Text.isSayText('привет', 'привет мир'); // -> true * * // Поиск одной из подстрок * Text.isSayText(['привет', 'здравствуйте'], 'привет мир'); // -> true * * // Поиск по регулярному выражению * Text.isSayText(['\\bпривет\\b', '\\bмир\\b'], 'привет мир', true); // -> true * ``` */ static isSayText(find, text, isPattern = false, useDirectRegExp = false, customReg = undefined) { if (!text) return false; if (isPattern) { return Text.#isSayPattern(find, text, useDirectRegExp, customReg); } const oneFind = Array.isArray(find) && find.length === 1 ? find[0] : find; if (typeof oneFind === 'string') { if (text.length < oneFind.length) { return false; } return text === oneFind || text.includes(oneFind); } else if ((0, RegExp_1.isRegex)(oneFind)) { return this.#isSayPattern(oneFind, text, useDirectRegExp, customReg); } // Оптимизированный вариант для массива: early return + includes for (let i = 0; i < find.length; i++) { const value = find[i]; if ((0, RegExp_1.isRegex)(value)) { if (this.#isSayPattern(value, text, useDirectRegExp, customReg)) { return true; } } else { if (text.length < value.length) { continue; } if (text === value || text.includes(value)) { return true; } } } return false; } /** * Получает или создает регулярное выражение из кэша * * @param {string} pattern - Шаблон регулярного выражения * @param {RegExpConstructor} customReg - Произвольный обработчик для регулярных выражений * @returns {RegExp} Скомпилированное регулярное выражение */ static #getCachedRegex(pattern, customReg = undefined) { const key = typeof pattern === 'string' ? pattern : `${pattern.flags}@@${pattern.source}`; const cache = Text.#regexCache.get(key); let regex = cache?.regex; if (!regex) { if (Text.#regexCache.size >= MAX_CACHE_SIZE) { // При переполнении кэша чистим 30% редко используемых команд const entries = [...Text.#regexCache.entries()].sort((tValue, oValue) => { return tValue[1].cReq - oValue[1].cReq; }); const toRemove = Math.floor(MAX_CACHE_SIZE * 0.3); for (let i = 0; i < toRemove; i++) { Text.#regexCache.delete(entries[i][0]); } } if (typeof pattern === 'string') { regex = (0, RegExp_1.getRegExp)(pattern, 'ium', customReg); Text.#regexCache.set(pattern, { cReq: 1, regex, }); } else { regex = (0, RegExp_1.getRegExp)(pattern, 'ium', customReg); Text.#regexCache.set(key, { cReq: 1, regex, }); } } else if (cache) { cache.cReq++; Text.#regexCache.set(key, cache); } return regex; } /** * Очищает кэш регулярных выражений. * Стоит вызывать только в крайних случаях */ static clearCache() { Text.#regexCache.clear(); } /** * Возвращает случайную строку из массива или исходную строку * * @param {TPattern} str - Строка или массив строк * @returns {string} Выбранная строка * * @example * ```ts * Text.getText('привет'); // -> 'привет' * Text.getText(['привет', 'здравствуйте']); // -> случайная строка из массива * ``` */ static getText(str) { if (str) { return Array.isArray(str) ? str[(0, util_1.rand)(0, str.length - 1)] : str; } return ''; } /** * Заменяет ключ в тексте на значение * @param {string} key - Ключ для замены * @param {string | string[]} value - Значение для замены * @param {string} text - Исходный текст */ static textReplace(key, value, text) { const correctKey = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); return text.replace(new RegExp(correctKey, 'g'), () => Text.getText(value)); } /** * Возвращает правильное окончание слова в зависимости от числа * * @param {number} num - Число для определения окончания * @param {readonly string[]} titles - Варианты окончаний ['один', 'два-четыре', 'пять-десять'] * @param {number | null} [index=null] - Принудительный индекс варианта окончания * @returns {string | null} Выбранное окончание или null, если не найдено * * @example * ```ts * const titles = ['яблоко', 'яблока', 'яблок']; * Text.getEnding(1, titles); // -> 'яблоко' * Text.getEnding(2, titles); // -> 'яблока' * Text.getEnding(5, titles); // -> 'яблок' * * // Принудительный выбор формы * Text.getEnding(5, titles, 0); // -> 'яблоко' * ``` */ static getEnding(num, titles, index = null) { if (index !== null && titles[index] !== undefined) { return titles[index]; } const absNum = Math.abs(num); const cases = [2, 0, 1, 1, 1, 2]; const titleIndex = absNum % 100 > 4 && absNum % 100 < 20 ? 2 : cases[Math.min(absNum % 10, 5)]; return titles[titleIndex] || null; } /** * Проверяет схожесть текстов и возвращает результат сравнения * * @param {string} origText - Оригинальный текст для сравнения * @param {TPattern} compareText - Текст или массив текстов для сравнения * @param {number} [threshold=80] - Минимальный процент схожести для положительного результата * @returns {ITextSimilarity} Результат сравнения текстов * * @example * ```ts * // Сравнение с одним текстом * Text.textSimilarity('привет', 'привт', 80); * // -> { * // status: true, * // index: 0, * // percent: 90, * // text: 'привт' * // } * * // Сравнение с массивом текстов * Text.textSimilarity('привет', ['привт', 'здравствуйте'], 80); * // -> { * // status: true, * // index: 0, * // percent: 90, * // text: 'привт' * // } * ``` */ static textSimilarity(origText, compareText, threshold = 80) { const texts = Array.isArray(compareText) ? compareText : [compareText]; const normalizedOrigText = origText.toLowerCase(); let maxSimilarity = { percent: 0, index: null, status: false, text: null, }; // Check for exact matches first const exactMatch = texts.findIndex((t) => t.toLowerCase() === normalizedOrigText); if (exactMatch !== -1) { return { index: exactMatch, status: true, percent: 100, text: texts[exactMatch], }; } // Find best similarity if no exact match texts.forEach((currentText, index) => { const similarity = (0, util_1.similarText)(normalizedOrigText, currentText.toLowerCase()); if (similarity > maxSimilarity.percent) { maxSimilarity = { percent: similarity, index: index, status: similarity >= threshold, text: currentText, }; } }); return maxSimilarity; } } exports.Text = Text;