Постановка проблемы
Те, кто учат английский язык наверняка знакомы с Anki — программой для запоминания слов, выражений и любой другой информации с помощью интервальных повторений.
Другой популярный сервис, не нуждающийся в представлении — LinguaLeo позволяет при чтении оригинального текста сразу отправлять незнакомые слова на изучение, сохраняя их в собственном словаре вместе с произношением, изображением, транскрипцией слова и контекстом в котором оно употребляется. Пару лет назад LinguaLeo внедрили систему интервальных повторений, но в отличии от 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'] = '<img src="%s" />' % 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.

