Збираємо базу аудіокниг для зручної фільтрації

Всім привіт! Напевно багатьом з вас знайома проблема втомлених очей з тривалою роботою за комп'ютером. На жаль, із-за цього доводиться обмежувати себе в інших заняттях. Одним з них є читання книг. У зв'язку з цим, я вже більше 5 років майже кожен день слухаю аудіокниги. За цей час навчився паралельно займатися чимось і вникати в суть озвучки. Зараз я навіть в спортзалі слухаю книги! Уявіть як це зручно: година дороги пішки туди і назад + півтори години вправ. Середня книга в районі 10-15 годин запису.

З часом все частіше і частіше з'являлася проблема вибору матеріалу. Адже досить велику роль відіграє читець, жанр книги. Часто виникає ситуація, коли хтось радить книгу (або в тій же статті на хабре в читальному залі), а аудіо-версії банально немає ще. Всі ці проблеми я спробував вирішити окремим сайтом. Зараз є кілька досить великих і розкручених аудіокниг, де ви можете прямо онлайн слухати їх. Такі сайти мають досить слабким фільтром за книгами. І, по суті, є чисто каталогом.



Джерело інформації
За весь час я помітив, що рутрекер є одним з найбільш масштабних сховищ аудіокниг. Якщо книга існує в такому форматі, то майже напевно вона є в роздачах. Багато читці навіть вручну роблять релізи торрентів. Першим завданням було повною синхронізацією всіх доступних аудіокниг з рутрекера.

Вибір книги
Наступною метою було створення широкого фільтра для підбору книги. Зручні фільтри допоможуть змінити підхід до вибору книги. Якщо раніше ви просто знаходили собі варіант, а потім шукали його аудіокнигу (якої могло не виявитися), то тепер ви виключаєте перший пункт і шукаєте в базі максимально всіх існуючих книг. Конкретно зараз у мене вийшло зробити наступний набір фільтрів:
  • Семантичний глобальний пошук по всій базі за всіма текстових полів
  • Сортування (asc/desc) за датою створення торрента, кількістю переглядів (на сайті), рейтингу (з зовнішніх джерел), кількість завантажень (за даними рутрекера), ну і навмання
  • Фільтр авторові твору, автору озвучки, жанрами, і можливість виключити книги, які ви відзначили як «прочитане»
  • Можливість підписки на авторів книг або озвучення. Так-так! Ви можете вибрати вподобаного виконавця і підписатися на всі його оновлення. Я, наприклад, моніторю всі книги Ігоря Князєва

База рутрекера
Отже, перший пункт це аналіз публікацій рутрекера та формування бази. Для сховища вибрав MongoDB. По-перше, ідеально для купи не особливо пов'язаних даних, по-друге, ідеально показала себе в плані продуктивності. Та й взагалі розробляти сайт з простим «пробрасыванием» json з UI на базу дуже просто і займає мінімальний час. До речі, в MongoDB 3.2 додали left outer join.
Основною складністю було унифицирование інформації. Рутрекер хоч і змушує оформляти роздачі (за що їм спасибі), але все одно за 10 років (саме стільки часу пройшло з моменту публікації першої аудіокниги) оформлення відрізняється. Довелося відкривати навмання різні розділи і збирати можливі варіанти.

Скрипт парсера написана на пітоні, для емуляції браузера бібліотека mechanize, для роботи з DOM — BeautifulSoup.
Метод, який повертає об'єкт максимально емулює поведінка звичайного браузера. Другий метод отримує об'єкт браузера, авторизирується на рутрекере і повертає цей самий об'єкт, всередині якого вже зберігаються cookies авторизації.

def getBrowser():
br = mechanize.Browser()
cj = cookielib.LWPCookieJar()
br.set_cookiejar(cj)
br.set_handle_equiv(True)
br.set_handle_gzip(True)
br.set_handle_redirect(True)
br.set_handle_referer(True)
br.set_handle_robots(False)

br.set_handle_refresh(mechanize._http.HTTPRefreshProcessor(), max_time=1)

br.addheaders = [
('User-agent', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/43.0.2327.5 Safari/537.36'),
('Accept', 'text/html,application/xhtml+xml application/xml;q=0.9,image/webp,*/*;q=0.8'),
('Accept-Encoding', 'gzip, deflate, sdch'),
('Accept-Language', 'ru,en;q=0.8'),
]

return br

def rutrackerAuth():
params = {u 'login_username': '...', u 'login_password': '...', u 'login' : "}
data = urllib.urlencode(params)

url = 'http://rutracker.org/forum/login.php'
browser = getBrowser()
browser.open(url, data)

return browser


Сам по собі збір даних виглядає як набір регулярних виразів у різних варіаціях:

yearRegex = r Рік .*(\d{4}?)'
result['year'] = int(re.search(yearRegex, descContent, re.IGNORECASE).group(1))

# Приклад розбору дати створення торрента, де дата вказана в російській локалі
timeData = soupHandle.find('div', {'id' : 'tor-reged'}).find('span').encode_contents()
import locale
locale.setlocale(locale.LC_ALL, 'uk_ua.UTF-8')
result['creationTime'] = datetime.datetime.strptime(timeData, u'[ %d%b%y %H:%M ]')


Дуже важливо використовувати BULK-запити в mongo, щоб парсер не навантажував одиничними вставками базу. На щастя, все це робиться дуже просто:

BULK = tableHandle.initialize_unordered_bulk_op()

# Цикл...
BULK.find({'_id' : book['_id']}).upsert().update({'$set' : result})

BULK.execute()


Поле slug генерується пакетом slugify (pip install slugify).

Ось список усіх полів для кожної з книг, які я в підсумку зібрав:


Відразу ж не забуваємо створити індекс для всіх полів, за якими буде йти сортування або фільтрація:



Це сповільнить час вставки, але дуже прискорить вибірку. Синхронізація бази відбувається раз в день, тому другий варіант для сайту краще.

Завантаження даних відбувається за всіма подфорумам аудіокниг:

forums = [
{'id' : '1036'}, {'id' : '400'}, {'id' : '574'},
{'id' : '2387'}, {'id' : '2388'}, {'id' : '695'},
{'id' : '399'}, {'id' : '402'}, {'id' : '490'},
{'id' : '499'}, {'id' : '2325'}, {'id' : '2342'},
{'id' : '530'}, {'id' : '2152'}, {'id' : '403'},
{'id' : '716'}, {'id' : '2165'}
]

for i in xrange(pagesCount):
url = 'http://rutracker.org/forum/viewforum.php?f='+forum['id']+'&start=' + str(i*50) + '&sort=2&order=1'



Нормалізація бази
Дані ми завантажили, але є проблема: немає точності в зазначених даних. Хтось напише «Ст. Герасимов», хтось «В'ячеслав Герасимов». В одному місці вкажуть повне або альтернативне назва твір. Також постало питання в отриманні незалежної оцінки твору. Погуглив пару заголовків книг і подивився на видачу перших сайтів. Одним з них виявився fantlab.ru, який будує оцінку по голосам користувачів, має досить значну базу книг, містить повний опис жанру і піджанрів книг, точне ім'я автора і твору.


Ім'я автора, назву книги
Абсолютно вся інформація з скріншота парс і вноситься в базу. Всі поля вручну перевіряються членами спільноти fantlab. Все ідеально, але є одна проблема: як пов'язати роздачу з рутрекера і певну запис з fantlab? В роздачах не вказують окремо назви твору. Іноді навіть автора неправильно пишуть (або не вказують). По суті, повним джерелом інформації є заголовок. Всю біль можна побачити в наступному скріншоті роздач:


Чи варто говорити, що навіть виключивши весь текст в кутових дужках вбудований пошук на fantlab не справляється і не знаходить нічого. Вихід я знайшов, хоч і не зовсім витончений: phantomjs(selenium) + google.

У мене досить багато проектів використовують цю зв'язку, тому настроєний headless-браузер і базові скрипти для selenium готові для використання. По суті, я брав заголовок з рутрекера, додавав до нього приставку " fantlab" і гугл. Перший результат, який за шаблоном підходив за адресою твори парсился. Залишу пару зауважень з приводу phantomjs: дуже сильно тече пам'ять. Я давно вже зробив для себе кілька «милиць», які дозволяють процесу жити місяцями на сервері і не падати через брак пам'яті:

def resourceRequestedLogic(self):
driver.execute('executePhantomScript', {'script': "'
var page = this;
page.onResourceRequested = function(request, networkRequest) {
if (/\.(jpg|jpeg|png|gif|tif|tiff|mov|css)/i.test(request.url))
{
//console.log('Final with css! Suppressing image: '+ request.url);
networkRequest.abort();
return;
}
}
"', 'args': []})


Ця функція виконується в момент запиту якогось ресурсу і перевіряє його по масці медіа-файлів. Всі картинки і відео виключаються. Тобто вони навіть не завантажуються в пам'ять. Друга функція примусово скидає кеш. Викликати потрібно по таймеру раз в ~годину:

def clearDriverCache(self):
driver.execute('executePhantomScript', {'script': "'
var page = this;
page.clearMemoryCache();
"', 'args': []})


Відкриваємо гугл і вбиваємо йому в поле пошуку будь-який текст, щоб змінити UI (отримати результат видачі). Всі подальші запити будуть відбуватися на цій же сторінці.
driver.get('http://google.ru')
driver.find_element_by_css_selector('input[type="text"]').send_keys(u"Ім'я книги fantlab")
driver.find_element_by_css_selector('button').click()


Так як запити аяксовые, нам потрібно вручну перевіряти факт завантаження. У selenium для цього є деякі методи, які чекають поки певний елемент не з'явиться на сторінці.
count = 0
while True:
count += 1
time.sleep(0.25)
if count >= 3:
break

try:
link = driver.find_element_by_css_selector('a[href*="fantlab.ru/work"]')
if link:
return link.get_attribute('href')
except:
continue


Жанри
Наступний крок: приведення до одного виду всіх імен авторів і всіх жанрів. У деяких роздачах писали «жах», в інших «жахи». Тут на допомогу прийшла бібліотека pymorphy2: дозволяє отримати початкову форму слова.

# Прибираємо всі спец символи з рядка жанрів 
fullGenre = fullGenre.replace('/', ',').replace(';', ',').replace('--', '-').replace(u 'е', u 'е')
fullGenre = re.sub(r'[\.|"«»]', ",fullGenre)
fullGenre = re.sub(r'\[.*?\]', ",fullGenre)

# Розбиваємо жанри з комою, прибираємо порожні поля і початкові/кінцеві пробіли
allGenres = filter(None, fullGenre.split(','))
allGenres = [item.strip() for item in allGenres]

# Робимо список унікальним (прибираємо дублікати)
allGenres = list(set(allGenres))

insertGenresList = []

for genre in allGenres:
# Проходимо по кожному жанру, отримуємо його початкову форму
morphology = morph.parse(genre)[0]
genre = morphology.normal_form

insertGenresList.append(genre)


Імена авторів
З авторами можна було б теж щось придумати з бібліотекою pymorphy2: розбивати на слова, перевіряти входження слів і їх збіг. Але тут я згадав пункт про глобальний всього пошук по всіх полях. Це і буде рішенням. Для повнотекстового пошуку взяв sphinx. Він безпосередньо не дружить з mongodb, тому потрібно написати скрипт, який буде викидати xml з даними за вказаною схемою.

docset = ET.Element("sphinx:docset")
schema = ET.SubElement(docset, "sphinx:schema")

# Зберігаємо ID запису в базі, щоб потім витягати інформацію
idAttribute = ET.SubElement(schema, "sphinx:attr")
idAttribute.set("name", "mongoid")
idAttribute.set("type", "int")

# Далі перераховуємо всі поля, які повинні індексуватися
text = ET.SubElement(schema, "sphinx:field")
text.set("name", "audioauthor")

text = ET.SubElement(schema, "sphinx:field")
text.set("name", "bookauthor")

text = ET.SubElement(schema, "sphinx:field")
text.set("name", "title")

text = ET.SubElement(schema, "sphinx:field")
text.set("name", "publisher")

text = ET.SubElement(schema, "sphinx:field")
text.set("name", "description")

# Ми повинні вручну генерувати індекс для кожного запису книги і це обов'язково повинен бути атрибут з маєтком id
globalIterator = 0
all = bookTable.find()

# Прибираємо те, що може зламати xml розмітку
def safeText(data):
data = re.sub('<[^<]+?>', ' ', data)
data = "".join([c for c in data if c.isalpha() or c.isdigit() or c==' ']).rstrip()

return data

for card in all:
document = ET.SubElement(docset, "sphinx:document")
globalIterator += 1

# Цей самий обов'язковий id
document.set("id", str(globalIterator))

mongoid = ET.SubElement(document, "mongoid")
mongoid.text = str(card["_id"])

title = ET.SubElement(document, "audioauthor")
title.text = safeText(card["audioAuthor"])

# І далі все те ж для всіх полів...


Параметри у sphinx.conf:

source src_bookaudio
{
type = xmlpipe2
xmlpipe_command = python /path/to/sphinx.py

sql_attr_uint = mongoid
}

index bookaudio
{
morphology = stem_enru
charset_type = utf-8
source = src_bookaudio
path = /var/lib/sphinxsearch/data/bookaudio.main
}


І команда: indexer bookaudio --rotate

Як же використовувати пошук для уніфікації полів? Беремо список всіх авторів книг, і складаємо однакові входження. Вийде щось типу:
В'ячеслав Герасимов — 1324
Ігор Князєв — 432
...


authors = {}
for book in allBooks:
author = book['audioAuthor']
if author in authors:
authors[author] += 1
else:
authors[author] = 1


Те, що використовується максимально часто, напевно, є найбільш правильною формою. Беремо топові входження і робимо глобальний пошук по всім авторам.

import sphinxapi
client = sphinxapi.SphinxClient()
client.SetServer('localhost', 9312)
client.SetMatchMode(sphinxapi.SPH_MATCH_ALL)
client.SetLimits(0, 10000, 10000)

import operator
sorted_x = reversed(sorted(authors.items(), key=operator.itemgetter(1)))
counter = 0
for i in sorted_x:
print i[0].encode('utf-8'),
print ' - ' + str(i[1])

searchData = client.Query(i[0], 'bookaudio')
for match in searchData['matches']:
mongoId = int(match['attrs']['mongoid'])
BULK.find({'_id' : mongoId}).upsert().update({'$set' : {'audioAuthor' : i[0]}})


Всі схожі входження (у тому числі «Ст. Герасимов», наприклад) будуть замінені на найбільш використовувані форми.

Інтерфейс
Написання веб-інтерфейсу для всього цього не несе ніякої технічної складності. По суті, це надбудова для доступу до бази. Ось що у мене вийшло. Список найбільш завантажуваних аудіокниг за всю історію трекера:



І робота з фільтрами:



Як бачите, я захотів подивитися всі книги, озвучені Ігорем Князєвим за жанром «російська фантастика», відсортовані за кількістю завантажень на рутрекере (вгорі найбільш популярні).

Пробілом або натисканням на картки внизу розкривається інформація про книгу. Завдяки mongodb всі фільтри відпрацьовують миттєво по базі в 30к книг.

Завершення
Не все ідеально: база не скрізь точна, інтерфейс можна поліпшити. Фільтр за жанрами потрібно перевести в деревоподібну структуру. Все це робота за 3 дня і для особистого користування та вибору книг мені вистачає. Ви б користувалися таким сервісом?

Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.