python

Импорт и преобразование словаря LinguaLeo в флэш-карты Anki

  • суббота, 30 декабря 2017 г. в 03:13:08
https://habrahabr.ru/post/345864/
  • Python


Постановка проблемы


Те, кто учат английский язык наверняка знакомы с Anki — программой для запоминания слов, выражений и любой другой информации с помощью интервальных повторений.

Другой популярный сервис, не нуждающийся в представлении — LinguaLeo позволяет при чтении оригинального текста сразу отправлять незнакомые слова на изучение, сохраняя их в собственном словаре вместе с произношением, изображением, транскрипцией слова и контекстом в котором оно употребляется. Пару лет назад LinguaLeo внедрили систему интервальных повторений, но в отличии от Anki система повторений не такая мощная и не содержит в себе возможностей настройки.

Что если нам попытаться скрестить ужа с ежом использовать преимущества двух платформ? Взять сами слова из Лингва Лео вместе со всеми медиафайлами и информацией и использовать ресурсы Anki для их запоминания.

Обзор готовых решений


Для задачи уже существуют несколько готовых решений, но все они не очень дружелюбны к пользователю и требуют множества дополнительных действий: нужно самостоятельно создавать модели записей, шаблоны карточек, добавлять css стили, отдельно закачивать медиа-файлы и т.д. и т.п.

Кроме того, все они выполнены либо в виде отдельной программы (LinguaGet), либо в качестве дополнений к браузеру (раз, два), что тоже не очень удобно. В идеальной ситуации хотелось, чтобы пользователь мог получить новые карточки прямо из самой Anki.

Выбор инструментов


Anki 2.0 написана на Python 2.7 и поддерживает систему дополнений, являющихся отдельными модулями питона. Графический интерфейс стабильной версии использует PyQt 4.8.

Таким образом задачу можно описать одним предложением: пишем дополнение на питоне, запускающееся прямо из Anki и не требующее от пользователя никаких действий кроме ввода логина и пароля от LinguaLeo.

Решение


Весь процесс можно разделить на три части:

  1. Авторизация и получение словаря из LinguaLeo.
  2. Преобразование полученных данных в карточки Anki.
  3. Создание графического интерфейса.

Импорт данных


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-шаблоны оформления карточек (лицевой и оборотной стороны).

В нашем случае полей будет пять: само слово, перевод, транскрипция, ссылка на файл с произношением, ссылка на файл с изображением.

Заполнив все поля мы получим запись. Из записи в свою очередь можно сделать две карточки: «английский — русский» и «русский — английский».

flash-card

Карточки лежат в колодах (смерть Кощея в яйце, а яйцо в ларце).

Помимо вышеперечисленного нам потребуются функции, скачивающие аудио и изображения.

Код модуля с утилитами по созданию моделей, записей и т.д.
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)


Создание графического интерфейса


Так как наше дополнение стремится к минимализму, нам потребуется всего лишь:

  • два поля ввода (логин и пароль от ЛингваЛео)
  • один чек-бокс для возможности импортирования только неизученных слов
  • две кнопки (импорта и отмены)

image

Графический интерфейс
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 и программа не позволяет изменять её извне главного треда. Решение: основная затратная задача — скачивание медиа-файлов и передача данных в прогресс-бар происходит во втором потоке, в то время как в главном заполняются поля и сохраняются записи. Таким образом, получаем работающую строку прогресса без замороженного интерфейса.

Весь код модуля GUI
# -*- 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.