1
1
Files
audio-catalyst/internal/domain/services/metadata_service.go
Dmitriy Fofanov 72a66f1664 Добавить тесты репозитория файловой системы и реализовать функциональность журналирования файлов.
- Реализовать тесты для поиска MP3-файлов и переименования/организации папок книг в репозитории файловой системы.
- Создать FileLogger для записи сообщений в файл с поддержкой различных уровней журналирования и управления размером файлов.
- Разработать репозиторий RuTracker для обработки поиска торрентов, получения метаданных и загрузки торрент-файлов.
- Добавить тесты для нормализации URL в репозиторий RuTracker.
- Реализовать адаптер логгера TUI для отображения логов в терминальном интерфейсе и, при необходимости, для записи логов в базовый логгер.
- Создать менеджер TUI для управления пользовательским интерфейсом приложения, включая главное меню, экран обработки, настройки и отображение результатов.
2025-09-29 20:40:05 +03:00

521 lines
19 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 services
import (
"regexp"
"strconv"
"strings"
"audio-catalyst/internal/domain/entities"
"golang.org/x/text/encoding/charmap"
)
// MetadataService сервис для работы с метаданными
type MetadataService struct{}
// NewMetadataService создает новый сервис метаданных
func NewMetadataService() *MetadataService {
return &MetadataService{}
}
// ParseTopicMetadata парсит метаданные со страницы темы RuTracker
func (s *MetadataService) ParseTopicMetadata(htmlContent string, torrent entities.Torrent) (*entities.AudioBookMetadata, error) {
// Декодируем из Windows-1251 в UTF-8
decoder := charmap.Windows1251.NewDecoder()
decodedContent, err := decoder.String(htmlContent)
if err != nil {
decodedContent = htmlContent
}
audioBookService := NewAudioBookService()
cleanTitle := audioBookService.CleanTitle(torrent.Title)
metadata := &entities.AudioBookMetadata{
Tags: []string{},
Chapters: []entities.Chapter{},
Title: cleanTitle,
Subtitle: cleanTitle,
Authors: []string{},
Narrators: []string{},
Series: []string{},
Genres: []string{},
Description: "",
Language: "ru",
Explicit: false,
Abridged: false,
}
// Попытаться извлечь заголовок из HTML: <span style="font-size: 24px; ...">Заголовок</span>
if htmlTitle := s.extractHTMLTitle(decodedContent); htmlTitle != "" {
metadata.Title = htmlTitle
}
// Subtitle: берем из <title> страницы (полный заголовок)
if pageTitle := s.extractPageTitle(decodedContent); pageTitle != "" {
metadata.Subtitle = pageTitle
} else if metadata.Subtitle == "" {
metadata.Subtitle = metadata.Title
}
// Функция для извлечения значения после <span class="post-b">метка</span>: значение
extractPostBValue := func(label string) string {
pattern := `<span[^>]*class="[^"]*post-b[^"]*"[^>]*>` + regexp.QuoteMeta(label) + `</span>:\s*([^<\r\n]+)`
re := regexp.MustCompile(`(?i)` + pattern)
matches := re.FindStringSubmatch(decodedContent)
if len(matches) >= 2 {
value := strings.TrimSpace(matches[1])
return s.decodeHTMLEntities(value)
}
return ""
}
// Извлечение года выпуска
if yearStr := extractPostBValue("Год выпуска"); yearStr != "" {
yearMatch := regexp.MustCompile(`(\d{4})`).FindString(yearStr)
if yearMatch != "" && len(yearMatch) == 4 {
if yearMatch >= "1900" && yearMatch <= "2030" {
yearInt := 0
for _, digit := range yearMatch {
yearInt = yearInt*10 + int(digit-'0')
}
metadata.PublishedYear = &yearInt
}
}
}
// Извлечение автора
s.extractAuthors(extractPostBValue, metadata)
// Извлечение исполнителя (чтеца)
s.extractNarrators(extractPostBValue, metadata)
// Извлечение жанра
if genre := extractPostBValue("Жанр"); genre != "" {
genres := regexp.MustCompile(`[,;]\s*`).Split(genre, -1)
for i, g := range genres {
genres[i] = strings.TrimSpace(g)
}
metadata.Genres = genres
}
// Извлечение тегов (категории форума), например: "[Аудио] Российская фантастика, фэнтези, мистика, ужасы, фанфики"
s.extractTags(decodedContent, metadata)
// Извлечение издательства
s.extractPublisher(extractPostBValue, metadata)
// Извлечение ISBN и ASIN
s.extractISBN(extractPostBValue, decodedContent, metadata)
s.extractASIN(extractPostBValue, decodedContent, metadata)
// Извлечение описания
s.extractDescription(extractPostBValue, decodedContent, metadata)
// Извлечение серии
s.extractSeries(extractPostBValue, decodedContent, torrent, metadata)
// Определяем эксплицитность и сокращенность
s.detectContentFlags(decodedContent, metadata)
return metadata, nil
}
// extractAuthors извлекает авторов
func (s *MetadataService) extractAuthors(extractPostBValue func(string) string, metadata *entities.AudioBookMetadata) {
var authorParts []string
if surname := extractPostBValue("Фамилия автора"); surname != "" {
authorParts = append(authorParts, surname)
}
if name := extractPostBValue("Имя автора"); name != "" {
authorParts = append(authorParts, name)
}
if len(authorParts) >= 2 {
metadata.Authors = []string{strings.Join(authorParts, " ")}
} else if len(authorParts) == 1 {
metadata.Authors = []string{authorParts[0]}
}
if len(metadata.Authors) == 0 {
if author := extractPostBValue("Автор"); author != "" {
authors := regexp.MustCompile(`[,;]\s*`).Split(author, -1)
for i, a := range authors {
authors[i] = strings.TrimSpace(a)
}
metadata.Authors = authors
}
}
}
// extractNarrators извлекает чтецов
func (s *MetadataService) extractNarrators(extractPostBValue func(string) string, metadata *entities.AudioBookMetadata) {
if narrator := extractPostBValue("Исполнитель"); narrator != "" {
narrators := regexp.MustCompile(`[,;]\s*`).Split(narrator, -1)
for i, n := range narrators {
narrators[i] = strings.TrimSpace(n)
}
metadata.Narrators = narrators
return
}
narratorFields := []string{"Чтец", "Читает", "Диктор", "Narrator"}
for _, field := range narratorFields {
if narrator := extractPostBValue(field); narrator != "" {
narrators := regexp.MustCompile(`[,;]\s*`).Split(narrator, -1)
for i, n := range narrators {
narrators[i] = strings.TrimSpace(n)
}
metadata.Narrators = narrators
break
}
}
}
// extractPublisher извлекает издательство
func (s *MetadataService) extractPublisher(extractPostBValue func(string) string, metadata *entities.AudioBookMetadata) {
if publisher := extractPostBValue("Издательство"); publisher != "" {
publisher = regexp.MustCompile(`:\s*.*$`).ReplaceAllString(publisher, "")
publisher = strings.TrimSpace(publisher)
if publisher != "" {
metadata.Publisher = &publisher
return
}
}
publisherFields := []string{"Выпущено", "Выпуск", "Издано", "Publisher"}
for _, field := range publisherFields {
if pub := extractPostBValue(field); pub != "" {
pub = regexp.MustCompile(`:\s*.*$`).ReplaceAllString(pub, "")
pub = strings.TrimSpace(pub)
if pub != "" {
metadata.Publisher = &pub
break
}
}
}
}
// extractISBN извлекает ISBN
func (s *MetadataService) extractISBN(extractPostBValue func(string) string, decodedContent string, metadata *entities.AudioBookMetadata) {
if isbn := extractPostBValue("ISBN"); isbn != "" {
metadata.ISBN = &isbn
return
}
isbnPatterns := []string{
`(?i)isbn[\s:-]*(\d{3}[-\s]?\d{1}[-\s]?\d{3}[-\s]?\d{5}[-\s]?\d{1})`,
`(?i)isbn[\s:-]*(\d{1}[-\s]?\d{3}[-\s]?\d{5}[-\s]?\d{1})`,
`(?i)isbn[\s:-]*(\d{13})`,
`(?i)isbn[\s:-]*(\d{10})`,
}
for _, pattern := range isbnPatterns {
if isbnMatch := regexp.MustCompile(pattern).FindStringSubmatch(decodedContent); len(isbnMatch) >= 2 {
isbn := strings.ReplaceAll(isbnMatch[1], " ", "")
isbn = strings.ReplaceAll(isbn, "-", "")
if len(isbn) == 10 || len(isbn) == 13 {
metadata.ISBN = &isbn
break
}
}
}
}
// extractASIN извлекает ASIN
func (s *MetadataService) extractASIN(extractPostBValue func(string) string, decodedContent string, metadata *entities.AudioBookMetadata) {
if asin := extractPostBValue("ASIN"); asin != "" {
metadata.ASIN = &asin
return
}
asinPatterns := []string{
`(?i)asin[\s:-]*([A-Z0-9]{10})`,
`(?i)amazon[\s]*asin[\s:-]*([A-Z0-9]{10})`,
}
for _, pattern := range asinPatterns {
if asinMatch := regexp.MustCompile(pattern).FindStringSubmatch(decodedContent); len(asinMatch) >= 2 {
metadata.ASIN = &asinMatch[1]
break
}
}
}
// extractDescription извлекает описание
func (s *MetadataService) extractDescription(extractPostBValue func(string) string, decodedContent string, metadata *entities.AudioBookMetadata) {
// Сначала пытаемся вытащить "богатое" описание до <span class="post-br"> или следующего label
if rich := s.extractRichField(decodedContent, "Описание"); rich != "" {
metadata.Description = rich
return
}
// Простое извлечение через post-b
if desc := extractPostBValue("Описание"); desc != "" {
metadata.Description = desc
return
}
// Альтернативные поля
descFields := []string{"О книге", "Аннотация", "Summary", "About"}
for _, field := range descFields {
if rich := s.extractRichField(decodedContent, field); rich != "" {
metadata.Description = rich
return
}
if desc := extractPostBValue(field); desc != "" {
metadata.Description = desc
return
}
}
}
// extractRichField извлекает текст после <span class="post-b">label</span> с учётом вариантов двоеточия
func (s *MetadataService) extractRichField(html string, label string) string {
// Варианты:
// 1) <span class="post-b">Описание</span>: TEXT ...
// 2) <span class="post-b">Описание:</span> TEXT ...
// Останавливаемся на <span class="post-br">, следующем <span class="post-b"> или конце текста
pattern := `(?is)<span[^>]*class=["'][^"']*post-b[^"']*["'][^>]*>\s*` + regexp.QuoteMeta(label) + `\s*:?[\s\S]*?</span>\s*:?\s*(.+?)(?:<span[^>]*class=["'][^"']*post-br[^"']*["'][^>]*>|<span[^>]*class=["'][^"']*post-b[^"']*["'][^>]*>|$)`
re := regexp.MustCompile(pattern)
m := re.FindStringSubmatch(html)
if len(m) < 2 {
return ""
}
text := m[1]
// Удаляем HTML теги
text = regexp.MustCompile(`(?is)<[^>]+>`).ReplaceAllString(text, " ")
// Декодируем сущности и нормализуем пробелы
text = s.decodeHTMLEntities(strings.TrimSpace(text))
text = regexp.MustCompile(`\s+`).ReplaceAllString(text, " ")
// Убираем хвост вида ": 1." на конце (если вдруг остался из разметки)
text = regexp.MustCompile(`\s*:\s*\d+\.?$`).ReplaceAllString(text, "")
return strings.TrimSpace(text)
}
// extractSeries извлекает серию
func (s *MetadataService) extractSeries(extractPostBValue func(string) string, decodedContent string, torrent entities.Torrent, metadata *entities.AudioBookMetadata) {
// Ищем в post-b полях
if series := extractPostBValue("Серия"); series != "" {
metadata.Series = []string{series}
return
}
seriesFields := []string{"Цикл", "Сага", "Собрание сочинений", "Антология", "Series"}
for _, field := range seriesFields {
if series := extractPostBValue(field); series != "" {
metadata.Series = []string{series}
return
}
}
// Дополнительно: парсим упоминания в тексте/ссылках, например: "Цикл «Наследник»"
patterns := []string{
`(?is)<a[^>]*class=["'][^"']*postLink[^"']*["'][^>]*>\s*Цикл\s*[«\"]\s*([^»\"]+)\s*[»\"]\s*</a>`,
`(?i)Цикл\s*[«\"]\s*([^»\"]+)\s*[»\"]`,
`(?i)Серия\s*[«\"]\s*([^»\"]+)\s*[»\"]`,
`(?i)Сага\s*[«\"]\s*([^»\"]+)\s*[»\"]`,
}
for _, p := range patterns {
if m := regexp.MustCompile(p).FindStringSubmatch(decodedContent); len(m) >= 2 {
name := s.decodeHTMLEntities(strings.TrimSpace(m[1]))
if name != "" {
metadata.Series = []string{name}
return
}
}
}
}
// detectContentFlags определяет флаги контента
func (s *MetadataService) detectContentFlags(decodedContent string, metadata *entities.AudioBookMetadata) {
contentLower := strings.ToLower(decodedContent)
// Эксплицитность
explicitKeywords := []string{"18+", "эротик", "порно", "xxx", "adult", "explicit"}
for _, keyword := range explicitKeywords {
if strings.Contains(contentLower, keyword) {
metadata.Explicit = true
break
}
}
// Сокращенность
abridgedKeywords := []string{"сокращен", "краткая", "abridged", "shortened"}
for _, keyword := range abridgedKeywords {
if strings.Contains(contentLower, keyword) {
metadata.Abridged = true
break
}
}
}
// decodeHTMLEntities декодирует HTML-сущности (включая числовые)
func (s *MetadataService) decodeHTMLEntities(str string) string {
replacements := map[string]string{
"&amp;": "&",
"&lt;": "<",
"&gt;": ">",
"&quot;": "\"",
"&apos;": "'",
"&nbsp;": " ",
"&#39;": "'",
"&#34;": "\"",
"&laquo;": "«",
"&raquo;": "»",
"&ndash;": "",
"&mdash;": "—",
"&hellip;": "…",
}
result := str
for entity, replacement := range replacements {
result = strings.ReplaceAll(result, entity, replacement)
}
// Декодируем числовые сущности: десятичные и шестнадцатеричные
decRe := regexp.MustCompile(`&#(\d+);`)
result = decRe.ReplaceAllStringFunc(result, func(m string) string {
parts := decRe.FindStringSubmatch(m)
if len(parts) < 2 {
return m
}
if code, err := strconv.Atoi(parts[1]); err == nil && code > 0 {
return string(rune(code))
}
return m
})
hexRe := regexp.MustCompile(`&#x([0-9a-fA-F]+);`)
result = hexRe.ReplaceAllStringFunc(result, func(m string) string {
parts := hexRe.FindStringSubmatch(m)
if len(parts) < 2 {
return m
}
if code, err := strconv.ParseInt(parts[1], 16, 32); err == nil && code > 0 {
return string(rune(code))
}
return m
})
return result
}
// extractHTMLTitle ищет заголовок в разметке поста, затем в og:title и <title>
func (s *MetadataService) extractHTMLTitle(html string) string {
// Сначала пытаемся ограничиться телом первого поста
postBodyRe := regexp.MustCompile(`(?is)<div[^>]*class=["'][^"']*post_body[^"']*["'][^>]*>(.*?)</div>`)
scope := html
if m := postBodyRe.FindStringSubmatch(html); len(m) >= 2 {
scope = m[1]
}
// Ищем span с font-size: 24px (допускаем пробел между 24 и px)
spanRe := regexp.MustCompile(`(?is)<span[^>]*style=["'][^"']*font-size\s*:\s*24\s*px[^"']*["'][^>]*>\s*(.*?)\s*</span>`)
if m := spanRe.FindStringSubmatch(scope); len(m) >= 2 {
text := m[1]
text = regexp.MustCompile(`(?is)<[^>]+>`).ReplaceAllString(text, " ")
text = s.decodeHTMLEntities(strings.TrimSpace(text))
text = regexp.MustCompile(`\s+`).ReplaceAllString(text, " ")
return s.normalizeTitle(strings.TrimSpace(text))
}
// Фолбэк: og:title
if m := regexp.MustCompile(`(?is)<meta[^>]+property=["']og:title["'][^>]+content=["']([^"']+)["']`).FindStringSubmatch(html); len(m) >= 2 {
text := s.decodeHTMLEntities(strings.TrimSpace(m[1]))
text = regexp.MustCompile(`\s+`).ReplaceAllString(text, " ")
return s.normalizeTitle(strings.TrimSpace(text))
}
// Фолбэк: <title>
if m := regexp.MustCompile(`(?is)<title[^>]*>(.*?)</title>`).FindStringSubmatch(html); len(m) >= 2 {
text := regexp.MustCompile(`(?is)<[^>]+>`).ReplaceAllString(m[1], " ")
text = s.decodeHTMLEntities(strings.TrimSpace(text))
text = regexp.MustCompile(`\s+`).ReplaceAllString(text, " ")
return s.normalizeTitle(strings.TrimSpace(text))
}
return ""
}
// extractPageTitle извлекает содержимое тега <title> без агрессивной нормализации
func (s *MetadataService) extractPageTitle(html string) string {
re := regexp.MustCompile(`(?is)<title[^>]*>(.*?)</title>`)
m := re.FindStringSubmatch(html)
if len(m) < 2 {
return ""
}
text := m[1]
text = regexp.MustCompile(`(?is)<[^>]+>`).ReplaceAllString(text, " ")
text = s.decodeHTMLEntities(strings.TrimSpace(text))
text = regexp.MustCompile(`\s+`).ReplaceAllString(text, " ")
// Убираем хвосты вида " :: RuTracker.org" / " | RuTracker.org" / " - RuTracker.org"
text = s.stripSiteSuffix(text)
return strings.TrimSpace(text)
}
// stripSiteSuffix убирает служебные суффиксы сайта в конце заголовка
func (s *MetadataService) stripSiteSuffix(t string) string {
if t == "" {
return t
}
// Удаляем любые повторяющиеся разделители перед RuTracker.org на конце
re := regexp.MustCompile(`(?i)\s*(?:\s*(?:\||::|:|—|-)\s*)+RuTracker\.?org.*$`)
return strings.TrimSpace(re.ReplaceAllString(t, ""))
}
// normalizeTitle чистит служебные хвосты и префиксы автора
func (s *MetadataService) normalizeTitle(t string) string {
if t == "" {
return t
}
// Убираем упоминание сайта и мусорные хвосты
t = regexp.MustCompile(`(?i)\s*[:|\-—]*\s*RuTracker\.?org.*$`).ReplaceAllString(t, "")
t = regexp.MustCompile(`\s*\[[^\]]*\]\s*$`).ReplaceAllString(t, "") // хвосты в [скобках]
t = regexp.MustCompile(`\s*\([^\)]*\)\s*$`).ReplaceAllString(t, "") // хвосты в (скобках)
// Если формат "Автор - Название" / "Автор — Название" — берем правую часть
if parts := regexp.MustCompile(`\s+[\-—]\s+`).Split(t, 2); len(parts) == 2 {
// Берём правую часть, если она не пустая
if strings.TrimSpace(parts[1]) != "" {
return strings.TrimSpace(parts[1])
}
}
return strings.TrimSpace(t)
}
// extractTags ищет ссылку на раздел форума (viewforum.php) и кладёт её текст в Tags одним элементом
func (s *MetadataService) extractTags(html string, metadata *entities.AudioBookMetadata) {
re := regexp.MustCompile(`(?is)<a[^>]+href=["'][^"']*viewforum\\.php\?f=\d+[^"']*["'][^>]*>\s*(.*?)\s*</a>`)
matches := re.FindAllStringSubmatch(html, -1)
best := ""
bestScore := -1
bestLen := 0
for _, m := range matches {
if len(m) < 2 {
continue
}
text := m[1]
text = regexp.MustCompile(`(?is)<[^>]+>`).ReplaceAllString(text, " ")
text = s.decodeHTMLEntities(strings.TrimSpace(text))
text = regexp.MustCompile(`\s+`).ReplaceAllString(text, " ")
// Убираем префикс в квадратных скобках, например [Аудио]
text = regexp.MustCompile(`^\[[^\]]+\]\s*`).ReplaceAllString(text, "")
if text == "" {
continue
}
lower := strings.ToLower(text)
score := 0
for _, kw := range []string{"фантаст", "фэнтез", "мистик", "ужас", "фанфик"} {
if strings.Contains(lower, kw) {
score++
}
}
if score > bestScore || (score == bestScore && len(text) > bestLen) {
best = text
bestScore = score
bestLen = len(text)
}
}
if best != "" {
metadata.Tags = []string{best}
}
}