ci / build-test (push) Canceled after 0s
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 зелёные.
221 lines
8.6 KiB
C#
221 lines
8.6 KiB
C#
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() + "…";
|
||
}
|
||
}
|