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
}
// Функция для извлечения значения после метка: значение
extractPostBValue := func(label string) string {
pattern := `]*class="[^"]*post-b[^"]*"[^>]*>` + regexp.QuoteMeta(label) + `:\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) {
// Сначала пытаемся вытащить "богатое" описание до или следующего 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 извлекает текст после label с учётом вариантов двоеточия
func (s *MetadataService) extractRichField(html string, label string) string {
// Варианты:
// 1) Описание: TEXT ...
// 2) Описание: TEXT ...
// Останавливаемся на , следующем или конце текста
pattern := `(?is)]*class=["'][^"']*post-b[^"']*["'][^>]*>\s*` + regexp.QuoteMeta(label) + `\s*:?[\s\S]*?\s*:?\s*(.+?)(?:]*class=["'][^"']*post-br[^"']*["'][^>]*>|]*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)]*class=["'][^"']*postLink[^"']*["'][^>]*>\s*Цикл\s*[«\"]\s*([^»\"]+)\s*[»\"]\s*`,
`(?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(`([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 и
func (s *MetadataService) extractHTMLTitle(html string) string {
// Сначала пытаемся ограничиться телом первого поста
postBodyRe := regexp.MustCompile(`(?is)]*class=["'][^"']*post_body[^"']*["'][^>]*>(.*?)
`)
scope := html
if m := postBodyRe.FindStringSubmatch(html); len(m) >= 2 {
scope = m[1]
}
// Ищем span с font-size: 24px (допускаем пробел между 24 и px)
spanRe := regexp.MustCompile(`(?is)]*style=["'][^"']*font-size\s*:\s*24\s*px[^"']*["'][^>]*>\s*(.*?)\s*`)
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)]+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))
}
// Фолбэк:
if m := regexp.MustCompile(`(?is)]*>(.*?)`).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)]*>(.*?)`)
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}
}
}