[Из песочницы] Импорт и преобразование словаря LinguaLeo в флэш-карты Anki
Постановка проблемы
Те, кто учат английский язык наверняка знакомы с Anki — программой для запоминания слов, выражений и любой другой информации с помощью интервальных повторений.
Другой популярный сервис, не нуждающийся в представлении — LinguaLeo позволяет при чтении оригинального текста сразу отправлять незнакомые слова на изучение, сохраняя их в собственном словаре вместе с произношением, изображением, транскрипцией слова и контекстом в котором оно употребляется. Пару лет назад LinguaLeo внедрили систему интервальных повторений, но в отличии от Anki система повторений не такая мощная и не содержит в себе возможностей настройки.
Что если нам попытаться скрестить ужа с ежом использовать преимущества двух платформ? Взять сами слова из Лингва Лео вместе со всеми медиафайлами и информацией и использовать ресурсы Anki для их запоминания.
Обзор готовых решений
Для задачи уже существуют несколько готовых решений, но все они не очень дружелюбны к пользователю и требуют множества дополнительных действий: нужно самостоятельно создавать модели записей, шаблоны карточек, добавлять css стили, отдельно закачивать медиа-файлы и т.д. и т.п.
Кроме того, все они выполнены либо в виде отдельной программы (LinguaGet), либо в качестве дополнений к браузеру (раз, два), что тоже не очень удобно. В идеальной ситуации хотелось, чтобы пользователь мог получить новые карточки прямо из самой Anki.
Выбор инструментов
Anki 2.0 написана на Python 2.7 и поддерживает систему дополнений, являющихся отдельными модулями питона. Графический интерфейс стабильной версии использует PyQt 4.8.
Таким образом задачу можно описать одним предложением: пишем дополнение на питоне, запускающееся прямо из Anki и не требующее от пользователя никаких действий кроме ввода логина и пароля от LinguaLeo.
Решение
Весь процесс можно разделить на три части:
- Авторизация и получение словаря из LinguaLeo.
- Преобразование полученных данных в карточки Anki.
- Создание графического интерфейса.
Импорт данных
LinguaLeo не содержит официального API, но покопавшись в коде дополнения для браузера можно найти два нужных адреса:
userDictUrl = "http://lingualeo.com/userdict/json"
loginURL = "http://api.lingualeo.com/api/login"
Процедура авторизации не представляет из себя ничего сложного и хорошо описана в этой статье на Хабре.
JSON словаря содержит по сто слов на каждой странице. Используя urlencode передаём в параметре filter значение all, а в параметре page номер нужной страницы, проходим по каждой из них и сохраняем наш словарь.
import json
import urllib
import urllib2
from cookielib import CookieJar
class Lingualeo:
def __init__(self, email, password):
self.email = email
self.password = password
self.cj = CookieJar()
def auth(self):
url = "http://api.lingualeo.com/api/login"
values = {"email": self.email, "password": self.password}
return self.get_content(url, values)
def get_page(self, page_number):
url = 'http://lingualeo.com/ru/userdict/json'
values = {'filter': 'all', 'page': page_number}
return self.get_content(url, values)['userdict3']
def get_content(self, url, values):
data = urllib.urlencode(values)
opener = urllib2.build_opener(urllib2.HTTPCookieProcessor(self.cj))
req = opener.open(url, data)
return json.loads(req.read())
def get_all_words(self):
"""
The JSON consists of list "userdict3" on each page
Inside of each userdict there is a list of periods with names
such as "October 2015". And inside of them lay our words.
Returns: type == list of dictionaries
"""
words = []
have_periods = True
page_number = 1
while have_periods:
periods = self.get_page(page_number)
if len(periods) > 0:
for period in periods:
words += period['words']
else:
have_periods = False
page_number += 1
return words
Преобразование полученных данных в карточки Anki
Данные в Анки хранятся следующим образом: в коллекции содержатся модели, включающие css-стиль, список полей и html-шаблоны оформления карточек (лицевой и оборотной стороны).
В нашем случае полей будет пять: само слово, перевод, транскрипция, ссылка на файл с произношением, ссылка на файл с изображением.
Заполнив все поля мы получим запись. Из записи в свою очередь можно сделать две карточки: «английский — русский» и «русский — английский».
Карточки лежат в колодах (смерть Кощея в яйце, а яйцо в ларце).
Помимо вышеперечисленного нам потребуются функции, скачивающие аудио и изображения.
import os
from random import randint
from urllib2 import urlopen
from aqt import mw
from anki import notes
from lingualeo import styles
fields = ['en', 'transcription',
'ru', 'picture_name',
'sound_name', 'context']
def create_templates(collection):
template_eng = collection.models.newTemplate('en -> ru')
template_eng['qfmt'] = styles.en_question
template_eng['afmt'] = styles.en_answer
template_ru = collection.models.newTemplate('ru -> en')
template_ru['qfmt'] = styles.ru_question
template_ru['afmt'] = styles.ru_answer
return (template_eng, template_ru)
def create_new_model(collection, fields, model_css):
model = collection.models.new("LinguaLeo_model")
model['tags'].append("LinguaLeo")
model['css'] = model_css
for field in fields:
collection.models.addField(model, collection.models.newField(field))
template_eng, template_ru = create_templates(collection)
collection.models.addTemplate(model, template_eng)
collection.models.addTemplate(model, template_ru)
model['id'] = randint(100000, 1000000) # Essential for upgrade detection
collection.models.update(model)
return model
def is_model_exist(collection, fields):
name_exist = 'LinguaLeo_model' in collection.models.allNames()
if name_exist:
fields_ok = collection.models.fieldNames(collection.models.byName(
'LinguaLeo_model')) == fields
else:
fields_ok = False
return (name_exist and fields_ok)
def prepare_model(collection, fields, model_css):
"""
Returns a model for our future notes.
Creates a deck to keep them.
"""
if is_model_exist(collection, fields):
model = collection.models.byName('LinguaLeo_model')
else:
model = create_new_model(collection, fields, model_css)
# Create a deck "LinguaLeo" and write id to deck_id
model['did'] = collection.decks.id('LinguaLeo')
collection.models.setCurrent(model)
collection.models.save(model)
return model
def download_media_file(url):
destination_folder = mw.col.media.dir()
name = url.split('/')[-1]
abs_path = os.path.join(destination_folder, name)
resp = urlopen(url)
media_file = resp.read()
binfile = open(abs_path, "wb")
binfile.write(media_file)
binfile.close()
def send_to_download(word):
picture_url = word.get('picture_url')
if picture_url:
picture_url = 'http:' + picture_url
download_media_file(picture_url)
sound_url = word.get('sound_url')
if sound_url:
download_media_file(sound_url)
def fill_note(word, note):
note['en'] = word['word_value']
note['ru'] = word['user_translates'][0]['translate_value']
if word.get('transcription'):
note['transcription'] = '[' + word.get('transcription') + ']'
if word.get('context'):
note['context'] = word.get('context')
picture_url = word.get('picture_url')
if picture_url:
picture_name = picture_url.split('/')[-1]
note['picture_name'] = '' % picture_name
sound_url = word.get('sound_url')
if sound_url:
sound_name = sound_url.split('/')[-1]
note['sound_name'] = '[sound:%s]' % sound_name
return note
def add_word(word, model):
collection = mw.col
note = notes.Note(collection, model)
note = fill_note(word, note)
collection.addNote(note)
Создание графического интерфейса
Так как наше дополнение стремится к минимализму, нам потребуется всего лишь:
- два поля ввода (логин и пароль от ЛингваЛео)
- один чек-бокс для возможности импортирования только неизученных слов
- две кнопки (импорта и отмены)
class PluginWindow(QDialog):
def __init__(self, parent=None):
QDialog.__init__(self, parent)
self.initUI()
def initUI(self):
self.setWindowTitle('Import From LinguaLeo')
# Window Icon
if platform.system() == 'Windows':
path = os.path.join(os.path.dirname(__file__), 'favicon.ico')
loc = locale.getdefaultlocale()[1]
path = unicode(path, loc)
self.setWindowIcon(QIcon(path))
# Buttons and fields
self.importButton = QPushButton("Import", self)
self.cancelButton = QPushButton("Cancel", self)
self.importButton.clicked.connect(self.importButtonClicked)
self.cancelButton.clicked.connect(self.cancelButtonClicked)
loginLabel = QLabel('Your LinguaLeo Login:')
self.loginField = QLineEdit()
passLabel = QLabel('Your LinguaLeo Password:')
self.passField = QLineEdit()
self.passField.setEchoMode(QLineEdit.Password)
self.progressLabel = QLabel('Downloading Progress:')
self.progressBar = QProgressBar()
self.checkBox = QCheckBox()
self.checkBoxLabel = QLabel('Unstudied only?')
# Main layout - vertical box
vbox = QVBoxLayout()
# Form layout
fbox = QFormLayout()
fbox.setMargin(10)
fbox.addRow(loginLabel, self.loginField)
fbox.addRow(passLabel, self.passField)
fbox.addRow(self.progressLabel, self.progressBar)
fbox.addRow(self.checkBoxLabel, self.checkBox)
self.progressLabel.hide()
self.progressBar.hide()
# Horizontal layout for buttons
hbox = QHBoxLayout()
hbox.setMargin(10)
hbox.addStretch()
hbox.addWidget(self.importButton)
hbox.addWidget(self.cancelButton)
hbox.addStretch()
# Add form layout, then stretch and then buttons in main layout
vbox.addLayout(fbox)
vbox.addStretch(2)
vbox.addLayout(hbox)
# Set main layout
self.setLayout(vbox)
# Set focus for typing from the keyboard
# You have to do it after creating all widgets
self.loginField.setFocus()
self.show()
Приключение начинается, когда кроме обозначенного захочется создать прогресс-бар, чтобы пользователь не думал, что дополнение умерло в процессе загрузки.
Для работы прогресс бара без замораживания GUI нужно создать отдельный поток (используя QThread), где загружались бы все данные и создавались карточки, а в графический интерфейс отправлялся лишь счётчик хода работы. Здесь нас поджидает неприятность — информация в Анки хранится в базе данных SQlite и программа не позволяет изменять её извне главного треда. Решение: основная затратная задача — скачивание медиа-файлов и передача данных в прогресс-бар происходит во втором потоке, в то время как в главном заполняются поля и сохраняются записи. Таким образом, получаем работающую строку прогресса без замороженного интерфейса.
# -*- coding: utf-8 -*-
import locale
import os
import platform
import socket
import urllib2
from anki import notes
from aqt import mw
from aqt.utils import showInfo
from PyQt4.QtGui import (QDialog, QIcon, QPushButton, QHBoxLayout,
QVBoxLayout, QLineEdit, QFormLayout,
QLabel, QProgressBar, QCheckBox)
from PyQt4.QtCore import QThread, SIGNAL
from lingualeo import connect
from lingualeo import utils
from lingualeo import styles
class PluginWindow(QDialog):
def __init__(self, parent=None):
QDialog.__init__(self, parent)
self.initUI()
def initUI(self):
self.setWindowTitle('Import From LinguaLeo')
# Window Icon
if platform.system() == 'Windows':
path = os.path.join(os.path.dirname(__file__), 'favicon.ico')
loc = locale.getdefaultlocale()[1]
path = unicode(path, loc)
self.setWindowIcon(QIcon(path))
# Buttons and fields
self.importButton = QPushButton("Import", self)
self.cancelButton = QPushButton("Cancel", self)
self.importButton.clicked.connect(self.importButtonClicked)
self.cancelButton.clicked.connect(self.cancelButtonClicked)
loginLabel = QLabel('Your LinguaLeo Login:')
self.loginField = QLineEdit()
passLabel = QLabel('Your LinguaLeo Password:')
self.passField = QLineEdit()
self.passField.setEchoMode(QLineEdit.Password)
self.progressLabel = QLabel('Downloading Progress:')
self.progressBar = QProgressBar()
self.checkBox = QCheckBox()
self.checkBoxLabel = QLabel('Unstudied only?')
# Main layout - vertical box
vbox = QVBoxLayout()
# Form layout
fbox = QFormLayout()
fbox.setMargin(10)
fbox.addRow(loginLabel, self.loginField)
fbox.addRow(passLabel, self.passField)
fbox.addRow(self.progressLabel, self.progressBar)
fbox.addRow(self.checkBoxLabel, self.checkBox)
self.progressLabel.hide()
self.progressBar.hide()
# Horizontal layout for buttons
hbox = QHBoxLayout()
hbox.setMargin(10)
hbox.addStretch()
hbox.addWidget(self.importButton)
hbox.addWidget(self.cancelButton)
hbox.addStretch()
# Add form layout, then stretch and then buttons in main layout
vbox.addLayout(fbox)
vbox.addStretch(2)
vbox.addLayout(hbox)
# Set main layout
self.setLayout(vbox)
# Set focus for typing from the keyboard
# You have to do it after creating all widgets
self.loginField.setFocus()
self.show()
def importButtonClicked(self):
login = self.loginField.text()
password = self.passField.text()
unstudied = self.checkBox.checkState()
self.importButton.setEnabled(False)
self.checkBox.setEnabled(False)
self.progressLabel.show()
self.progressBar.show()
self.progressBar.setValue(0)
self.threadclass = Download(login, password, unstudied)
self.threadclass.start()
self.connect(self.threadclass, SIGNAL('Length'), self.progressBar.setMaximum)
self.setModel()
self.connect(self.threadclass, SIGNAL('Word'), self.addWord)
self.connect(self.threadclass, SIGNAL('Counter'), self.progressBar.setValue)
self.connect(self.threadclass, SIGNAL('FinalCounter'), self.setFinalCount)
self.connect(self.threadclass, SIGNAL('Error'), self.setErrorMessage)
self.threadclass.finished.connect(self.downloadFinished)
def setModel(self):
self.model = utils.prepare_model(mw.col, utils.fields, styles.model_css)
def addWord(self, word):
"""
Note is an SQLite object in Anki so you need
to fill it out inside the main thread
"""
utils.add_word(word, self.model)
def cancelButtonClicked(self):
if hasattr(self, 'threadclass') and not self.threadclass.isFinished():
self.threadclass.terminate()
mw.reset()
self.close()
def setFinalCount(self, counter):
self.wordsFinalCount = counter
def setErrorMessage(self, msg):
self.errorMessage = msg
def downloadFinished(self):
if hasattr(self, 'wordsFinalCount'):
showInfo("You have %d new words" % self.wordsFinalCount)
if hasattr(self, 'errorMessage'):
showInfo(self.errorMessage)
mw.reset()
self.close()
class Download(QThread):
def __init__(self, login, password, unstudied, parent=None):
QThread.__init__(self, parent)
self.login = login
self.password = password
self.unstudied = unstudied
def run(self):
words = self.get_words_to_add()
if words:
self.emit(SIGNAL('Length'), len(words))
self.add_separately(words)
def get_words_to_add(self):
leo = connect.Lingualeo(self.login, self.password)
try:
status = leo.auth()
words = leo.get_all_words()
except urllib2.URLError:
self.msg = "Can't download words. Check your internet connection."
except ValueError:
try:
self.msg = status['error_msg']
except:
self.msg = "There's been an unexpected error. Sorry about that!"
if hasattr(self, 'msg'):
self.emit(SIGNAL('Error'), self.msg)
return None
if self.unstudied:
words = [word for word in words if word.get('progress_percent') < 100]
return words
def add_separately(self, words):
"""
Divides downloading and filling note to different threads
because you cannot create SQLite objects outside the main
thread in Anki. Also you cannot download files in the main
thread because it will freeze GUI
"""
counter = 0
problem_words = []
for word in words:
self.emit(SIGNAL('Word'), word)
try:
utils.send_to_download(word)
except (urllib2.URLError, socket.error):
problem_words.append(word.get('word_value'))
counter += 1
self.emit(SIGNAL('Counter'), counter)
self.emit(SIGNAL('FinalCounter'), counter)
if problem_words:
self.problem_words_msg(problem_words)
def problem_words_msg(self, problem_words):
error_msg = ("We weren't able to download media for these "
"words because of broken links in LinguaLeo "
"or problems with an internet connection: ")
for problem_word in problem_words[:-1]:
error_msg += problem_word + ', '
error_msg += problem_words[-1] + '.'
self.emit(SIGNAL('Error'), error_msg)
Добавляем обработку ошибок и на этом всё. Дополнение готово к работе.
В планах переписать плагин на третий питон для новой, пока ещё бета-версии Анки и использовать асинхронную загрузку медиа-файлов для ускорения работы.
Исходный код на BitBucket.
Страница плагина на форуме дополнений Anki.