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: Заголовок if htmlTitle := s.extractHTMLTitle(decodedContent); htmlTitle != "" { metadata.Title = htmlTitle } // Subtitle: берем из страницы (полный заголовок) 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{ "&": "&", "<": "<", ">": ">", """: "\"", "'": "'", " ": " ", "'": "'", """: "\"", "«": "«", "»": "»", "–": "–", "—": "—", "…": "…", } 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[^>]*>(.*?)`).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 извлекает содержимое тега без агрессивной нормализации func (s *MetadataService) extractPageTitle(html string) string { re := regexp.MustCompile(`(?is)<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)]+href=["'][^"']*viewforum\\.php\?f=\d+[^"']*["'][^>]*>\s*(.*?)\s*`) 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} } }