Files
Deal/src/core/Deal.Modules.Pipeline/Application/Parse/MessageTextCleaner.cs
T
Rustam Khalimov 413eaac48c Вынести условия-предикаты в extension-методы
HasUser из 13 endpoint-файлов сведён в AuthHelpers.HasUser;
15 приватных предикатов заменены extension-методами с удалением
дублирующих приватных методов: IsCommunicationFailure,
IsTransportFailure, IsPrivateEndpoint, IsConfigured, IsTrue,
IsExpired, IsFailedLogin/IsSuccessfulLogin, HasChanges, HasBudget,
HasAnyTerm, IsCurrencyLetter, IsEmojiCodePoint, ContainsFooterHint,
IsTypeLabel.
2026-09-11 13:08:47 +03:00

269 lines
14 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Text;
using System.Text.RegularExpressions;
namespace Deal.Modules.Pipeline.Application.Parse;
/// <summary>
/// Чистка текстовых полей сообщения/карточки от markdown-разметки, ссылок и служебных символов
/// (pipeline.py clean_short L148156 / clean_block L158193, регэкспы/эмодзи L131–145).
/// </summary>
/// <remarks>
/// 1:1 с прототипом: markdown-ссылки <c>[текст](url)</c> → текст, <c>**__`~~</c>-пары снимаются,
/// <c>||спойлер||</c> и голые URL удаляются, «C#»/«F#» защищаются от вырезания решётки (письмо + #),
/// эмодзи-диапазоны, маркеры списков в начале строк и лишние переносы/пробелы схлопываются. <see cref="CleanShort"/>
/// дополнительно переводит переносы в пробелы (заголовок); <see cref="CleanBlock"/> сохраняет структуру строк
/// («О заявке» карточки). <see cref="CleanLine"/> — обрезка краёв строки (python <c>_clean_line</c> L682683).
/// </remarks>
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;
/// <summary>
/// Чистит текстовое поле в одну строку (заголовок, суть без структуры): как <see cref="CleanBlock"/>,
/// но переносы строк схлопываются в пробелы (python clean_short L148156).
/// </summary>
/// <param name="text">Сырой текст (markdown/ссылки/эмодзи); null → пустая строка (как <c>str(text or "")</c>).</param>
/// <param name="limit">Максимум кодовых точек результата; обрезка по границе переноса/пробела с многоточием
/// (L184192); null/0 — без обрезки.</param>
/// <returns>Очищенный однострочный текст.</returns>
public static string CleanShort(string? text, int? limit = null)
{
return NewlinesToSpaceRe.Replace(CleanBlock(text, limit), " ");
}
/// <summary>
/// Чистит блок текста с сохранением переносов строк (python clean_block L158193): применяет все
/// шаги прототипа в том же порядке (markdown → URL → защита «C#» → эмодзи → маркеры списков → пробелы →
/// обрезка краёв → лимит по границе).
/// </summary>
/// <param name="text">Сырой текст; null → пустая строка.</param>
/// <param name="limit">Максимум кодовых точек результата; обрезка по последнему переносу/пробелу ближе
/// середины лимита, иначе жёсткая по лимиту; в конец добавляется «…». null/0 — без обрезки.</param>
/// <returns>Очищенный текст с сохранённой структурой строк.</returns>
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;
}
/// <summary>
/// Обрезает строку по краевым служебным символам markdown (python <c>_clean_line</c> L682683):
/// убирает <c>[\s&gt;*#_~]</c> с краёв и тримит.
/// </summary>
/// <param name="line">Строка текста (может быть null — как <c>None</c> в python).</param>
/// <returns>Строка без краевого мусора (пустая, если мусора было больше).</returns>
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 L137145): доп. пиктограммы
// 1F0001FAFF (включая региональные флаги 1F1E6–1F1FF), разные символы 2600–27BF, стрелки 2B002BFF
// и 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 (codePoint.IsEmojiCodePoint())
{
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;
}
// Обрезка по границе последнего переноса/пробела в первых 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() + "…";
}
}