umbot
Version:
Мультиплатформенный фреймворк для создания голосовых навыков и чат-ботов с единой бизнес-логикой. Встроенная поддержка ВКонтакте, Telegram, Viber, MAX, Яндекс Алисы, Маруси и Сбера SmartApp. Архитектура на адаптерах позволяет подключать любые другие платф
454 lines (453 loc) • 18.4 kB
JavaScript
;
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;