Files
Deal/src/core/Deal.Modules.Pipeline/Application/Parse/MessageTextCleaner.cs
T
Rustam Khalimov 27c7831910
ci / build-test (push) Canceled after 0s
Deal — единая кодовая база
SaaS-мониторинг Telegram: ядро (модули Cards/Kanban/Pipeline/Tenants/Settings/
Discovery, Api, Infrastructure), сервисы telegram/ai/ml/storage, фронт Vue,
контракты и grpc-hosting, деплой-конфиги (dev/prod/observability/CI-раннер),
Gitea Actions CI, документация (ТЗ, техдок, api-map, код-стайл, планы, бэклог).

Текущее состояние: все этапы роадмапа 0–12 закрыты, сборка 5 sln 0/0,
тесты 1340/130/52/38/9 зелёные.
2026-09-11 23:56:47 +03:00

221 lines
8.6 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-разметки, ссылок и служебных символов.
/// </summary>
public static class MessageTextCleaner
{
private const string HashGuard = "\u2063";
private const string ZeroWidthSpace = "\u200b";
private const string NonBreakingSpace = "\u00a0";
private static readonly Regex MarkdownLinkRe = new(@"\[([^\]]*)\]\([^)\s]+\)", RegexOptions.CultureInvariant);
private static readonly Regex MarkdownBoldRe = new(@"\*\*(.+?)\*\*", RegexOptions.CultureInvariant);
private static readonly Regex MarkdownBold2Re = new(@"__([^_\n]+?)__", RegexOptions.CultureInvariant);
private static readonly Regex MarkdownCodeRe = new(@"`([^`\n]+?)`", RegexOptions.CultureInvariant);
private static readonly Regex MarkdownStrikeRe = new(@"~~([^~\n]+?)~~", RegexOptions.CultureInvariant);
private static readonly Regex BareUrlRe = new(@"https?://[^\s<>""']+", RegexOptions.CultureInvariant);
private static readonly Regex HashProtectRe = new(@"\b([A-Za-zА-Яа-яЁё])\#", RegexOptions.CultureInvariant);
private static readonly Regex MarkdownSymbolsRe = new("[*`#>~]+", RegexOptions.CultureInvariant);
private static readonly Regex LineStartMarkersRe = new(@"(?m)^[\s>#*\-–—•▪▫●○‣]+\s*", RegexOptions.CultureInvariant);
private static readonly Regex SpaceCollapseRe = new(@"[ \t]+", RegexOptions.CultureInvariant);
private static readonly Regex LineIndentRe = new(@"\n[ \t]+", RegexOptions.CultureInvariant);
private static readonly Regex MultiNewlineRe = new(@"\n{2,}", RegexOptions.CultureInvariant);
private static readonly Regex NewlinesToSpaceRe = new(@"\n+", RegexOptions.CultureInvariant);
private static readonly Regex LineEdgesRe = new(@"^[\s*>#_~]+|[\s*>#_~]+$", RegexOptions.CultureInvariant);
private static readonly char[] EdgeTrimChars = " \t\n\r-–—·•|:;,".ToCharArray();
// Одноразовые кодовые точки-разделители для ручного прохода символов.
private const int EmptyCodePoint = -1;
/// <summary>
/// Чистит текстовое поле в одну строку
/// </summary>
/// <param name="text">Сырой текст (markdown/ссылки/эмодзи); null → пустая строка (как <c>str(text or "")</c>).</param>
/// <param name="limit">Максимум кодовых точек результата; обрезка по границе переноса/пробела с многоточием; null/0 — без обрезки.</param>
/// <returns>Очищенный однострочный текст.</returns>
public static string CleanShort(string? text, int? limit = null)
{
return NewlinesToSpaceRe.Replace(CleanBlock(text, limit), " ");
}
/// <summary>
/// Чистит блок текста с сохранением переносов строк
/// </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;
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
/// </summary>
/// <param name="line">Строка текста.</param>
/// <returns>Строка без краевого мусора (пустая, если мусора было больше).</returns>
public static string CleanLine(string? line)
{
return LineEdgesRe.Replace(line ?? string.Empty, string.Empty).Trim();
}
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;
}
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();
}
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;
}
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() + "…";
}
}