using System.Text; using System.Text.RegularExpressions; namespace Deal.Modules.Pipeline.Application.Parse; /// /// Чистка текстовых полей сообщения/карточки от markdown-разметки, ссылок и служебных символов /// (pipeline.py clean_short L148–156 / clean_block L158–193, регэкспы/эмодзи L131–145). /// /// /// 1:1 с прототипом: markdown-ссылки [текст](url) → текст, **__`~~-пары снимаются, /// ||спойлер|| и голые URL удаляются, «C#»/«F#» защищаются от вырезания решётки (письмо + #), /// эмодзи-диапазоны, маркеры списков в начале строк и лишние переносы/пробелы схлопываются. /// дополнительно переводит переносы в пробелы (заголовок); сохраняет структуру строк /// («О заявке» карточки). — обрезка краёв строки (python _clean_line L682–683). /// public static class MessageTextCleaner { // Защитный символ, временно заменяющий решётку после буквы («C#», прототип L172/L176). private const string HashGuard = "\u2063"; // Символ zero-width space (фото-превью Telegram), удаляется (прототип L173). private const string ZeroWidthSpace = "\u200b"; // Неразрывный пробел, заменяется обычным (прототип L174). private const string NonBreakingSpace = "\u00a0"; // Markdown-ссылка [текст](url) → текст ссылки (прототип _MD_LINK_RE L131). private static readonly Regex MarkdownLinkRe = new(@"\[([^\]]*)\]\([^)\s]+\)", RegexOptions.CultureInvariant); // Жирный **текст** (прототип _MD_BOLD L598). private static readonly Regex MarkdownBoldRe = new(@"\*\*(.+?)\*\*", RegexOptions.CultureInvariant); // Жирный __текст__ (прототип _MD_BOLD2_RE L132). private static readonly Regex MarkdownBold2Re = new(@"__([^_\n]+?)__", RegexOptions.CultureInvariant); // Код `текст` (прототип _MD_CODE_RE L133). private static readonly Regex MarkdownCodeRe = new(@"`([^`\n]+?)`", RegexOptions.CultureInvariant); // Зачёркнутый ~~текст~~ (прототип _MD_STRIKE_RE L134). private static readonly Regex MarkdownStrikeRe = new(@"~~([^~\n]+?)~~", RegexOptions.CultureInvariant); // Голая URL-ссылка (прототип _BARE_URL_RE L135). private static readonly Regex BareUrlRe = new(@"https?://[^\s<>""']+", RegexOptions.CultureInvariant); // Защита решётки в составе названия: «C#»/«F#» (прототип L172). private static readonly Regex HashProtectRe = new(@"\b([A-Za-zА-Яа-яЁё])\#", RegexOptions.CultureInvariant); // Служебные символы markdown в тексте (прототип L175). private static readonly Regex MarkdownSymbolsRe = new("[*`#>~]+", RegexOptions.CultureInvariant); // Маркеры списков/декоративные буллеты в начале строк (прототип L179). private static readonly Regex LineStartMarkersRe = new(@"(?m)^[\s>#*\-–—•▪▫●○‣]+\s*", RegexOptions.CultureInvariant); // Схлопывание пробелов и табуляций (прототип L180). private static readonly Regex SpaceCollapseRe = new(@"[ \t]+", RegexOptions.CultureInvariant); // Пробелы/табуляции после переноса строки (прототип L181). private static readonly Regex LineIndentRe = new(@"\n[ \t]+", RegexOptions.CultureInvariant); // Подряд идущие переносы строк → один (прототип L182). private static readonly Regex MultiNewlineRe = new(@"\n{2,}", RegexOptions.CultureInvariant); // Переносы строк в пробелы для clean_short (прототип L155). private static readonly Regex NewlinesToSpaceRe = new(@"\n+", RegexOptions.CultureInvariant); // Служебные символы на краях строки (python _MD_EDGES L597). private static readonly Regex LineEdgesRe = new(@"^[\s*>#_~]+|[\s*>#_~]+$", RegexOptions.CultureInvariant); // Символы, обрезаемые с краёв готового блока (прототип L183). private static readonly char[] EdgeTrimChars = " \t\n\r-–—·•|:;,".ToCharArray(); // Одноразовые кодовые точки-разделители для ручного прохода символов. private const int EmptyCodePoint = -1; /// /// Чистит текстовое поле в одну строку (заголовок, суть без структуры): как , /// но переносы строк схлопываются в пробелы (python clean_short L148–156). /// /// Сырой текст (markdown/ссылки/эмодзи); null → пустая строка (как str(text or "")). /// Максимум кодовых точек результата; обрезка по границе переноса/пробела с многоточием /// (L184–192); null/0 — без обрезки. /// Очищенный однострочный текст. public static string CleanShort(string? text, int? limit = null) { return NewlinesToSpaceRe.Replace(CleanBlock(text, limit), " "); } /// /// Чистит блок текста с сохранением переносов строк (python clean_block L158–193): применяет все /// шаги прототипа в том же порядке (markdown → URL → защита «C#» → эмодзи → маркеры списков → пробелы → /// обрезка краёв → лимит по границе). /// /// Сырой текст; null → пустая строка. /// Максимум кодовых точек результата; обрезка по последнему переносу/пробелу ближе /// середины лимита, иначе жёсткая по лимиту; в конец добавляется «…». null/0 — без обрезки. /// Очищенный текст с сохранённой структурой строк. public static string CleanBlock(string? text, int? limit = null) { string s = text ?? string.Empty; // Порядок 1:1 с прототипом (L163–183). s = MarkdownLinkRe.Replace(s, m => m.Groups[1].Value.Trim()); s = MarkdownBoldRe.Replace(s, "$1"); s = MarkdownBold2Re.Replace(s, "$1"); s = MarkdownCodeRe.Replace(s, "$1"); s = MarkdownStrikeRe.Replace(s, "$1"); s = s.Replace("||", string.Empty, StringComparison.Ordinal); s = BareUrlRe.Replace(s, " "); s = HashProtectRe.Replace(s, m => m.Groups[1].Value + HashGuard); s = s.Replace(ZeroWidthSpace, string.Empty, StringComparison.Ordinal); s = s.Replace(NonBreakingSpace, " ", StringComparison.Ordinal); s = MarkdownSymbolsRe.Replace(s, " "); s = s.Replace(HashGuard, "#", StringComparison.Ordinal); s = RemoveEmojiCodePoints(s); s = LineStartMarkersRe.Replace(s, string.Empty); s = SpaceCollapseRe.Replace(s, " "); s = LineIndentRe.Replace(s, "\n"); s = MultiNewlineRe.Replace(s, "\n"); s = s.Trim(EdgeTrimChars); if (limit is > 0 && CountCodePoints(s) > limit.Value) { s = CutByBoundary(s, limit.Value); } return s; } /// /// Обрезает строку по краевым служебным символам markdown (python _clean_line L682–683): /// убирает [\s>*#_~] с краёв и тримит. /// /// Строка текста (может быть null — как None в python). /// Строка без краевого мусора (пустая, если мусора было больше). public static string CleanLine(string? line) { return LineEdgesRe.Replace(line ?? string.Empty, string.Empty).Trim(); } // Количество кодовых точек в строке (python len() — позиции суррогатных пар считаются одной). // value: Строка (не null). // Возвращает: Число кодовых точек. internal static int CountCodePoints(string value) { int count = 0; for (int index = 0; index < value.Length; index++) { count++; if (char.IsHighSurrogate(value[index]) && index + 1 < value.Length && char.IsLowSurrogate(value[index + 1])) { index++; } } return count; } // Первые max кодовых точек строки (python-срез s[:max] без разрыва // суррогатных пар). // value: Строка. // max: Максимум кодовых точек; ≤0 или ≥ длины — строка как есть. // Возвращает: Усечённая строка. internal static string SliceCodePoints(string value, int max) { if (max <= 0) { return string.Empty; } if (CountCodePoints(value) <= max) { return value; } var builder = new StringBuilder(value.Length); int taken = 0; for (int index = 0; index < value.Length && taken < max; index++) { bool pair = char.IsHighSurrogate(value[index]) && index + 1 < value.Length && char.IsLowSurrogate(value[index + 1]); builder.Append(value[index]); if (pair) { index++; builder.Append(value[index]); } taken++; } return builder.ToString(); } // Убирает декоративные эмодзи/символы-маркеры (python _EMOJI_RE L137–145): доп. пиктограммы // 1F000–1FAFF (включая региональные флаги 1F1E6–1F1FF), разные символы 2600–27BF, стрелки 2B00–2BFF // и variation selector FE0F. // value: Текст после снятия markdown-разметки. // Возвращает: Текст без эмодзи-диапазонов (суррогатные пары удаляются целиком). private static string RemoveEmojiCodePoints(string value) { var builder = new StringBuilder(value.Length); for (int index = 0; index < value.Length; index++) { int codePoint = DecodeCodePoint(value, index, out int length); if (codePoint == EmptyCodePoint) { builder.Append(value[index]); // непарный суррогат: не эмодзи — сохраняем как есть (1:1 python) continue; } if (IsEmojiCodePoint(codePoint)) { index += length - 1; continue; } builder.Append(value, index, length); index += length - 1; } return builder.ToString(); } // Кодовая точка с позиции строки (суррогатная пара — целиком). // value: Строка. // index: Позиция символа. // length: Длина последовательности в UTF-16 единицах (1 или 2). // Возвращает: Кодовая точка или EmptyCodePoint для непарного суррогата. private static int DecodeCodePoint(string value, int index, out int length) { char current = value[index]; if (char.IsHighSurrogate(current) && index + 1 < value.Length && char.IsLowSurrogate(value[index + 1])) { length = 2; return char.ConvertToUtf32(current, value[index + 1]); } if (char.IsLowSurrogate(current) || char.IsHighSurrogate(current)) { length = 1; return EmptyCodePoint; } length = 1; return current; } // Входит ли кодовая точка в эмодзи-диапазоны прототипа (L137–145). // codePoint: Кодовая точка (BMP или доп. плоскость). // Возвращает: True — декоративный символ, подлежащий удалению. private static bool IsEmojiCodePoint(int codePoint) { return codePoint is >= 0x1F000 and <= 0x1FAFF or >= 0x2600 and <= 0x27BF or >= 0x2B00 and <= 0x2BFF or 0xFE0F; } // Обрезка по границе последнего переноса/пробела в первых limit кодовых точках // (прототип L184–192): выбирается перенос (или пробел), если он после середины лимита; иначе режем жёстко. // value: Текст длиннее лимита. // limit: Лимит кодовых точек. // Возвращает: Усечённый текст с многоточием в конце. private static string CutByBoundary(string value, int limit) { string cut = SliceCodePoints(value, limit); int lineBreak = cut.LastIndexOf('\n'); int space = cut.LastIndexOf(' '); int at = lineBreak > limit / 2 ? lineBreak : (space > limit / 2 ? space : -1); if (at >= 0) { cut = cut[..at]; } return cut.TrimEnd() + "…"; } }