Files
jellybit/internal/recognize/sanitize.go
T
av 9aecf757e0 recognize: название из метабазы санитизируется перед попаданием в план
- чистка стоит на каждой точке входа значения метабазы в план — сборка матча,
  копия кандидата для ревью, набор закреплённых значений источника и его
  чтение: гарантия, поставленная только на запись, обходится данными,
  сохранёнными прежними версиями
- название, непригодное как имя каталога (пустое или без единой буквы и
  цифры), не подставляется — раздача уходит в review с названной причиной
- гейт подтверждения матча не сдвинут: сравнение с планом идёт по значениям
  провайдера, чистится только копия, уходящая дальше
2026-08-10 10:41:16 +03:00

196 lines
8.2 KiB
Go
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.
package recognize
import (
"log/slog"
"strings"
"unicode"
"git.vakhrushev.me/av/jellybit/internal/metadata"
)
// 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
}
// UsableTitle отвечает, годится ли название как компонент пути к файлу. Годится
// то, в чём после санитайзинга осталась хотя бы одна буква или цифра: layout
// снимает разделители и обрезает края от точек и пробелов, поэтому «.», «...»,
// «-» и прочая голая пунктуация схлопываются там в пустое имя каталога, а
// ведущая точка вдобавок делает каталог скрытым для Jellyfin.
//
// Проверять этим предикатом надо УЖЕ санитизированное значение: невидимые
// символы буквами не являются, но и не мешают — их снимает SanitizeTitle.
// Экспортирована вместе с SanitizeTitle, потому что тот же вопрос задаёт worker
// при закреплении выбранного человеком источника: правило одно, домов у него
// быть не должно.
func UsableTitle(s string) bool {
for _, r := range s {
if unicode.IsLetter(r) || unicode.IsDigit(r) {
return true
}
}
return false
}
// sanitizeCandidate чистит человекочитаемые названия кандидата метабазы. Зовётся
// на КОПИИ кандидата в момент, когда он уходит из сверки дальше — в список для
// review, в хранилище, на экран и в закрепляемое человеком значение. Значения,
// по которым ищется сильный матч, при этом не меняются: сравнение обязано идти
// по тому, что отдал провайдер (см. metadata-match), иначе кандидат, отличающийся
// от плана невидимым символом, начал бы совпадать там, где прежде уходил в review.
func sanitizeCandidate(c metadata.Candidate) metadata.Candidate {
c.Title = SanitizeTitle(c.Title)
c.OriginalTitle = SanitizeTitle(c.OriginalTitle)
return c
}
// 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)
}