Инициализировать репозиторий «Дейл»
Первый коммит: модульный монолит ядра (.NET 10) и gRPC-сервисы ai/ml/telegram, фронтенд Vue 3/Vite/Tailwind, документация (ТЗ, инструкция пользователя, техдокументация, код-стайл), бэклог, скрипты развёртывания и архив прототипа LeadRadar.
This commit is contained in:
@@ -0,0 +1,279 @@
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace Deal.Modules.Pipeline.Application.Parse;
|
||||
|
||||
/// <summary>
|
||||
/// Чистка текстовых полей сообщения/карточки от markdown-разметки, ссылок и служебных символов
|
||||
/// (pipeline.py clean_short L148–156 / clean_block L158–193, регэкспы/эмодзи 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> L682–683).
|
||||
/// </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 L148–156).
|
||||
/// </summary>
|
||||
/// <param name="text">Сырой текст (markdown/ссылки/эмодзи); null → пустая строка (как <c>str(text or "")</c>).</param>
|
||||
/// <param name="limit">Максимум кодовых точек результата; обрезка по границе переноса/пробела с многоточием
|
||||
/// (L184–192); null/0 — без обрезки.</param>
|
||||
/// <returns>Очищенный однострочный текст.</returns>
|
||||
public static string CleanShort(string? text, int? limit = null)
|
||||
{
|
||||
return NewlinesToSpaceRe.Replace(CleanBlock(text, limit), " ");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Чистит блок текста с сохранением переносов строк (python clean_block L158–193): применяет все
|
||||
/// шаги прототипа в том же порядке (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> L682–683):
|
||||
/// убирает <c>[\s>*#_~]</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 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() + "…";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user