package recognize import ( "log/slog" "strings" "unicode" ) // homoglyphPairs — курируемая таблица визуально неотличимых кирилло-латинских // пар (двойников). Реальная боль — русскоклавиатурные двойники в англоязычных // названиях (кейс «Hаrold» с кир. `а`). Таблица используется в обе стороны: // направление свёртки выбирает foldHomoglyphs по доминирующему скрипту токена. // Единственный источник правды и для санитайзинга плана, и для нормализации в // гейте матча (normalize). var homoglyphPairs = []struct{ cyr, lat rune }{ // строчные {'а', 'a'}, {'е', 'e'}, {'о', 'o'}, {'р', 'p'}, {'с', 'c'}, {'у', 'y'}, {'х', 'x'}, {'к', 'k'}, // заглавные {'А', 'A'}, {'В', 'B'}, {'Е', 'E'}, {'К', 'K'}, {'М', 'M'}, {'Н', 'H'}, {'О', 'O'}, {'Р', 'P'}, {'С', 'C'}, {'Т', 'T'}, {'Х', 'X'}, } var ( cyr2lat = map[rune]rune{} lat2cyr = map[rune]rune{} ) func init() { for _, p := range homoglyphPairs { cyr2lat[p.cyr] = p.lat lat2cyr[p.lat] = p.cyr } } // sanitizeTitle чистит человекочитаемое поле плана как недоверенный вывод LLM: // (1) убирает управляющие и zero-width символы; (2) сводит пробелы к одиночным и // обрезает края; (3) сворачивает homoglyph-двойники. Порядок важен: strip делаем // до collapse, чтобы удаление zero-width не оставляло сдвоенных пробелов. func sanitizeTitle(s string) string { s = stripControl(s) s = collapseSpaces(s) s = foldHomoglyphs(s) return s } // sanitizePlan применяет санитайзинг к человекочитаемым полям плана. files[].src // НЕ трогаем: они обязаны байт-в-байт совпадать с реальными файлами торрента, и // homoglyph там — настоящий mismatch (отклоняется валидацией, уходит в review), // а не повод «чинить» путь. Логируем на Debug, когда значение реально изменилось // (названия не относятся к секретам). func sanitizePlan(p *Plan, log *slog.Logger) { p.Title = sanitizeField(p.Title, "title", log) p.OriginalTitle = sanitizeField(p.OriginalTitle, "original_title", log) p.ProviderHint = sanitizeField(p.ProviderHint, "provider_hint", log) } func sanitizeField(v, field string, log *slog.Logger) string { clean := sanitizeTitle(v) if clean != v && log != nil { log.Debug("recognition plan field sanitized", "field", field, "before", v, "after", clean) } return clean } // stripControl удаляет format-символы (zero-width, BOM, soft-hyphen — категория // Cf) и управляющие C0/C1, кроме пробельных (\t\n\r и пр. остаются — их сведёт // collapseSpaces). func stripControl(s string) string { return strings.Map(func(r rune) rune { switch { case unicode.Is(unicode.Cf, r): return -1 case unicode.IsControl(r) && !unicode.IsSpace(r): return -1 default: return r } }, s) } // collapseSpaces сводит последовательности пробельных к одиночному пробелу и // обрезает края. func collapseSpaces(s string) string { var b strings.Builder b.Grow(len(s)) pendingSpace := false for _, r := range s { if unicode.IsSpace(r) { pendingSpace = true continue } if pendingSpace && b.Len() > 0 { b.WriteByte(' ') } pendingSpace = false b.WriteRune(r) } return b.String() } // foldHomoglyphs сворачивает кирилло-латинские homoglyph-двойники потокенно. // Токен (максимальная последовательность букв) из одного скрипта не трогаем — // билингвальность реальна, честная кириллица неприкосновенна. В смешанном токене // определяем доминирующий скрипт по числу буквенных рун и заменяем // буквы-меньшинство их двойниками из доминирующего скрипта; при равенстве // скриптов токен не меняем. Не-буквенные символы (пробелы, цифры, пунктуация) // разделяют токены и копируются как есть. func foldHomoglyphs(s string) string { runes := []rune(s) var b strings.Builder b.Grow(len(s)) for i := 0; i < len(runes); { if !unicode.IsLetter(runes[i]) { b.WriteRune(runes[i]) i++ continue } j := i for j < len(runes) && unicode.IsLetter(runes[j]) { j++ } b.WriteString(foldToken(runes[i:j])) i = j } return b.String() } func foldToken(tok []rune) string { var cyr, lat int for _, r := range tok { switch { case unicode.Is(unicode.Cyrillic, r): cyr++ case unicode.Is(unicode.Latin, r): lat++ } } if cyr == 0 || lat == 0 { return string(tok) // одно-скриптовый токен — не трогаем } var m map[rune]rune switch { case lat > cyr: m = cyr2lat // доминирует латиница — сворачиваем кириллические двойники case cyr > lat: m = lat2cyr default: return string(tok) // нет доминирующего скрипта — не трогаем } out := make([]rune, len(tok)) for i, r := range tok { if repl, ok := m[r]; ok { out[i] = repl } else { out[i] = r } } return string(out) }