Добавить тесты репозитория файловой системы и реализовать функциональность журналирования файлов.
- Реализовать тесты для поиска MP3-файлов и переименования/организации папок книг в репозитории файловой системы. - Создать FileLogger для записи сообщений в файл с поддержкой различных уровней журналирования и управления размером файлов. - Разработать репозиторий RuTracker для обработки поиска торрентов, получения метаданных и загрузки торрент-файлов. - Добавить тесты для нормализации URL в репозиторий RuTracker. - Реализовать адаптер логгера TUI для отображения логов в терминальном интерфейсе и, при необходимости, для записи логов в базовый логгер. - Создать менеджер TUI для управления пользовательским интерфейсом приложения, включая главное меню, экран обработки, настройки и отображение результатов.
This commit is contained in:
520
internal/domain/services/metadata_service.go
Normal file
520
internal/domain/services/metadata_service.go
Normal file
@@ -0,0 +1,520 @@
|
||||
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{
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
""": "\"",
|
||||
"'": "'",
|
||||
" ": " ",
|
||||
"'": "'",
|
||||
""": "\"",
|
||||
"«": "«",
|
||||
"»": "»",
|
||||
"–": "–",
|
||||
"—": "—",
|
||||
"…": "…",
|
||||
}
|
||||
|
||||
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}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user