Добавить тесты репозитория файловой системы и реализовать функциональность журналирования файлов.
- Реализовать тесты для поиска MP3-файлов и переименования/организации папок книг в репозитории файловой системы. - Создать FileLogger для записи сообщений в файл с поддержкой различных уровней журналирования и управления размером файлов. - Разработать репозиторий RuTracker для обработки поиска торрентов, получения метаданных и загрузки торрент-файлов. - Добавить тесты для нормализации URL в репозиторий RuTracker. - Реализовать адаптер логгера TUI для отображения логов в терминальном интерфейсе и, при необходимости, для записи логов в базовый логгер. - Создать менеджер TUI для управления пользовательским интерфейсом приложения, включая главное меню, экран обработки, настройки и отображение результатов.
This commit is contained in:
132
internal/domain/services/audiobook_service.go
Normal file
132
internal/domain/services/audiobook_service.go
Normal file
@@ -0,0 +1,132 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"audio-catalyst/internal/domain/entities"
|
||||
)
|
||||
|
||||
// AudioBookService сервис для работы с аудиокнигами
|
||||
type AudioBookService struct{}
|
||||
|
||||
// NewAudioBookService создает новый сервис аудиокниг
|
||||
func NewAudioBookService() *AudioBookService {
|
||||
return &AudioBookService{}
|
||||
}
|
||||
|
||||
// CreateChapters создает главы из MP3 файлов
|
||||
func (s *AudioBookService) CreateChapters(mp3Files []string) []entities.Chapter {
|
||||
// Гарантируем пустой слайс, а не nil (в JSON будет [] вместо null)
|
||||
chapters := make([]entities.Chapter, 0, len(mp3Files))
|
||||
|
||||
// Сортировка файлов по имени
|
||||
sortedFiles := make([]string, len(mp3Files))
|
||||
copy(sortedFiles, mp3Files)
|
||||
sort.Strings(sortedFiles)
|
||||
|
||||
currentStart := 0.0
|
||||
for i, file := range sortedFiles {
|
||||
fileName := filepath.Base(file)
|
||||
baseName := strings.TrimSuffix(fileName, ".mp3")
|
||||
chapterTitle := s.extractChapterTitle(baseName)
|
||||
|
||||
// Оцениваем длительность файла в секундах
|
||||
durSec := s.estimateDuration(file)
|
||||
if durSec < 0 {
|
||||
durSec = 0
|
||||
}
|
||||
|
||||
chapter := entities.Chapter{
|
||||
ID: i,
|
||||
Start: currentStart,
|
||||
End: currentStart + durSec,
|
||||
Title: chapterTitle,
|
||||
Duration: int(durSec),
|
||||
}
|
||||
|
||||
currentStart += durSec
|
||||
chapters = append(chapters, chapter)
|
||||
}
|
||||
|
||||
return chapters
|
||||
}
|
||||
|
||||
// extractChapterTitle извлекает название главы из имени файла
|
||||
func (s *AudioBookService) extractChapterTitle(fileName string) string {
|
||||
name := strings.TrimSuffix(fileName, ".mp3")
|
||||
|
||||
patterns := []string{
|
||||
`^(\d{1,3})[\s\-_.]*(.*)$`,
|
||||
`^[Cc]hapter[\s_]*(\d+)(.*)$`,
|
||||
`^[Гг]лава[\s_]*(\d+)(.*)$`,
|
||||
`^[Чч]асть[\s_]*(\d+)(.*)$`,
|
||||
}
|
||||
|
||||
for _, pattern := range patterns {
|
||||
re := regexp.MustCompile(pattern)
|
||||
if matches := re.FindStringSubmatch(name); len(matches) >= 2 {
|
||||
number := matches[1]
|
||||
title := ""
|
||||
if len(matches) >= 3 {
|
||||
title = strings.TrimSpace(matches[2])
|
||||
title = strings.TrimLeft(title, "-_. ")
|
||||
}
|
||||
|
||||
if title != "" {
|
||||
return fmt.Sprintf("%s - %s", number, title)
|
||||
}
|
||||
return number
|
||||
}
|
||||
}
|
||||
|
||||
return name
|
||||
}
|
||||
|
||||
// estimateDuration оценивает продолжительность MP3 файла (в секундах)
|
||||
// Приближение: 1 MB ≈ 0.86 минуты (≈51.6 секунды) при средних битрейтах
|
||||
func (s *AudioBookService) estimateDuration(filePath string) float64 {
|
||||
info, err := os.Stat(filePath)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
// Размер файла в MB
|
||||
sizeMB := float64(info.Size()) / (1024.0 * 1024.0)
|
||||
// Примерная продолжительность в минутах
|
||||
estimatedMinutes := sizeMB * 0.86
|
||||
return estimatedMinutes * 60.0
|
||||
}
|
||||
|
||||
// CleanTitle очищает название книги от технической информации
|
||||
func (s *AudioBookService) CleanTitle(title string) string {
|
||||
// Удаляем информацию в квадратных скобках в конце
|
||||
title = regexp.MustCompile(`\s*\[.*?\]\s*$`).ReplaceAllString(title, "")
|
||||
|
||||
// Удаляем информацию в круглых скобках в конце
|
||||
title = regexp.MustCompile(`\s*\(.*?\)\s*$`).ReplaceAllString(title, "")
|
||||
|
||||
// Удаляем технические обозначения
|
||||
techPatterns := []string{
|
||||
`\s*MP3\s*$`,
|
||||
`\s*FLAC\s*$`,
|
||||
`\s*\d+\s*kbps\s*$`,
|
||||
`\s*\d+\s*кбит/с\s*$`,
|
||||
`\s*VBR\s*$`,
|
||||
`\s*CBR\s*$`,
|
||||
`\s*\d{4}\s*$`,
|
||||
}
|
||||
|
||||
for _, pattern := range techPatterns {
|
||||
title = regexp.MustCompile(`(?i)`+pattern).ReplaceAllString(title, "")
|
||||
}
|
||||
|
||||
// Убираем лишние пробелы
|
||||
title = strings.TrimSpace(title)
|
||||
title = regexp.MustCompile(`\s+`).ReplaceAllString(title, " ")
|
||||
|
||||
return title
|
||||
}
|
||||
66
internal/domain/services/audiobook_service_test.go
Normal file
66
internal/domain/services/audiobook_service_test.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCreateChaptersAndDurations(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
files := []string{"02.mp3", "01.mp3"}
|
||||
var paths []string
|
||||
// создаём разные размеры для разной длительности
|
||||
for i, f := range files {
|
||||
p := filepath.Join(dir, f)
|
||||
data := make([]byte, (i+1)*1024*1024) // 1MB, 2MB
|
||||
if err := os.WriteFile(p, data, 0644); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
paths = append(paths, p)
|
||||
}
|
||||
|
||||
svc := NewAudioBookService()
|
||||
chapters := svc.CreateChapters(paths)
|
||||
if len(chapters) != 2 {
|
||||
t.Fatalf("ожидалось 2 главы, получено %d", len(chapters))
|
||||
}
|
||||
// порядок по имени: 01.mp3, 02.mp3
|
||||
if chapters[0].Title != "01" || chapters[1].Title != "02" {
|
||||
t.Errorf("неверные названия глав: %+v", chapters)
|
||||
}
|
||||
if !(chapters[0].Duration > 40 && chapters[1].Duration > chapters[0].Duration) {
|
||||
t.Errorf("неверные длительности: %+v", chapters)
|
||||
}
|
||||
if !(chapters[0].Start == 0 && chapters[0].End < chapters[1].Start) {
|
||||
t.Errorf("неверные интервалы: %+v", chapters)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractChapterTitlePatterns(t *testing.T) {
|
||||
svc := NewAudioBookService()
|
||||
cases := map[string]string{
|
||||
"01 - Введение": "01 - Введение",
|
||||
"001_Первая_глава": "001 - Первая_глава",
|
||||
"Chapter 05 - Название": "05 - Название",
|
||||
"Глава 3 - Важная информация": "3 - Важная информация",
|
||||
"Часть 10": "10",
|
||||
"простое_название": "простое_название",
|
||||
"15": "15",
|
||||
}
|
||||
for in, exp := range cases {
|
||||
got := svc.extractChapterTitle(in)
|
||||
if got != exp {
|
||||
t.Errorf("%q => %q, ожидалось %q", in, got, exp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanTitle(t *testing.T) {
|
||||
svc := NewAudioBookService()
|
||||
in := "Автор - Книга [MP3, 96 kbps] (2025)"
|
||||
got := svc.CleanTitle(in)
|
||||
if got != "Автор - Книга" {
|
||||
t.Errorf("получено %q", got)
|
||||
}
|
||||
}
|
||||
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}
|
||||
}
|
||||
}
|
||||
42
internal/domain/services/metadata_service_test.go
Normal file
42
internal/domain/services/metadata_service_test.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"audio-catalyst/internal/domain/entities"
|
||||
)
|
||||
|
||||
func TestExtractHTMLTitleAndSubtitle(t *testing.T) {
|
||||
s := NewMetadataService()
|
||||
html := `<html><head><title>Автор - Полный тайтл :: RuTracker.org</title></head>
|
||||
<div class="post_body"><span style="font-size: 24px; line-height: normal;">Наследник. Книга 03</span></div></html>`
|
||||
m, err := s.ParseTopicMetadata(html, entities.Torrent{Title: "Папка"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if m.Title != "Наследник. Книга 03" {
|
||||
t.Errorf("title=%q", m.Title)
|
||||
}
|
||||
if m.Subtitle != "Автор - Полный тайтл" {
|
||||
t.Errorf("subtitle=%q", m.Subtitle)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractSeriesAndTags(t *testing.T) {
|
||||
s := NewMetadataService()
|
||||
h := `<div class="post_body">
|
||||
<a href="viewforum.php?f=2387">[Аудио] Российская фантастика, фэнтези, мистика, ужасы, фанфики</a>
|
||||
<a href="tracker.php?f=2387" class="postLink">Цикл «Наследник»</a>
|
||||
<span class="post-b">Описание</span>: Текст
|
||||
</div>`
|
||||
m, err := s.ParseTopicMetadata(h, entities.Torrent{Title: "X"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(m.Series) == 0 || m.Series[0] != "Наследник" {
|
||||
t.Errorf("series=%v", m.Series)
|
||||
}
|
||||
if len(m.Tags) == 0 || m.Tags[0] != "Российская фантастика, фэнтези, мистика, ужасы, фанфики" {
|
||||
t.Errorf("tags=%v", m.Tags)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user