Частотный анализатор английских слов, написаный на python 3, умеющий нормализовывать слова с помощью WordNet и переводить с помощью StarDict

Привет всем!
Я учу английский и всячески упрощаю этот процесс. Как-то мне потребовалось получить список слов вместе с переводом и транскрипцией для определенного текста. Задача не была сложной, и я принялась за дело. Чуть позднее был написан скрипт на python, все это умеющий, и даже умеющий чуть больше, поскольку мне захотелось получить еще и частотный словарь из всех файлов с английским текстом внутри. Так вышел маленький набор скриптов, о котором я и хотела бы рассказать.
Работа скрипта заключается в распарсивании файлов, выделении английских слов, нормализации их, подсчете и выдачи первыx countWord слов из всего получившегося списка английских слов.
В итоговом файле слово записывается в виде:
[число повторений] [само слово] [перевод слова]

О чем будет дальше:
  1. Мы начнем с получения списка английских слов из файла (используя регулярные выражения);
  2. Дальше начнем нормализовывать слова, то есть приводить их с естественной формы в тот вид, в котором они хранятся в словарях (тут мы немного изучим формат WordNet);
  3. Затем мы подсчитаем количество вхождений у всех нормализованных слов (это быстро и просто);
  4. Дальше мы углубимся в формат StarDict, потому что именно с помощью него получим переводы и транскрипцию.
  5. Ну и в самом конце мы куда-нибудь запишем результат (я выбрала файл формата Excel).


Я использовала python 3.3 и надо сказать не один раз пожалела, что не пишу на python 2.7, поскольку часто не хватало нужных модулей.

Частотный анализатор.


Итак, начнем с простого, получим файлы, распарсим их на слова, подсчитаем, отсортируем, и выдадим результат.
Для начала составим регулярное выражение для поиска английских слов в тексте.

Регулярное выражение для поиска английских слов

Простое английское слово, например «over», можно найти, используя выражение "([a-zA-Z]+)" — здесь ищется одна или более букв английского алфавита.
Составное слово, к примеру «commander-in-chief», найти несколько сложнее, нам нужно искать идущие друг за другом подвыражения вида «commander-», «in-», после которых идет слово «chief». Регулярное выражение примет вид "(([a-zA-Z]+-?)*[a-zA-Z]+)".
Если в выражении присутсвует промежуточное подвыражение, оно тоже включается в результат. Так, в наш результат попадает не только слово «commander-in-chief», но также и все найденные подвыражения, Чтобы их исключить, добавим в начале подвыражеения '?:' стразу после открывающейся круглой скобки. Тогда регулярное выражение примет вид "((?:[a-zA-Z]+-?)*[a-zA-Z]+)". Нам еще осталось включить в выражения слова с апострофом вида «didn't». Для этого заменим в первом подвыражении "-?" на "[-']?".
Все, на этом закончим улучшения регулярного выражения, его можно было бы улучшать и дальше, но остановимся на таком:
"((?:[a-zA-Z]+[-']?)*[a-zA-Z]+)"

Реализация частотного анализатора английских слов


Напишем маленький класс, умеющий извлекать английские слова, считать их и выдавать результат.
# -*- coding: utf-8 -*- 

import re
import os
from collections import Counter


class FrequencyDict:
	def __init__():
		
		# Определяем регулярное выражение для поиска английских слов
		self.wordPattern = re.compile("((?:[a-zA-Z]+[-']?)*[a-zA-Z]+)")
		
		# Частотный словарь(использум класс collections.Counter для поддержки подсчёта уникальных элементов в последовательностях) 		
		self.frequencyDict = Counter()
		
	# Метод парсит файл, получает из него слова
	def ParseBook(self, file):
		if file.endswith(".txt"): 
			self.__ParseTxtFile(file, self.__FindWordsFromContent)
		else:
			print('Warning: The file format is not supported: "%s"' %file)
			
	# Метод парсит файл в формате txt
	def __ParseTxtFile(self, txtFile, contentHandler):
		try:
			with open(txtFile, 'rU') as file:		
				for line in file: # Читаем файл построчно
					contentHandler(line) # Для каждой строки вызываем обработчик контента
		except Exception as e:
			print('Error parsing "%s"' % txtFile, e)	
						
	# Метод находит в строке слова согласно своим правилам и затем добавляет в частотный словарь
	def __FindWordsFromContent(self, content):
		result = self.wordPattern.findall(content) # В строке найдем список английских слов				
		for word in result:
			word = word.lower()	# Приводим слово к нижнему регистру	
				self.frequencyDict[word] += 1 # Добавляем в счетчик частотного словаря не нормализованное слово	
	
	
	# Метод отдает первые countWord слов частотного словаря, отсортированные по ключу и значению
	def FindMostCommonElements(self, countWord):
		dict = list(self.frequencyDict.items())
		dict.sort(key=lambda t: t[0])
		dict.sort(key=lambda t: t[1], reverse = True)
		return dict[0 : int(countWord)]



На этом, в сущности, работа с частотным словарем могла бы быть и закончена, но наша работа только начинается. Все дело в том, что слова в тексте пишутся с учетом грамматических правил, а это значит, что в тексте могут встретиться слова с окончаниями ed, ing и тд. По сути, даже формы глагола to be ( am, is, are) будут засчитываться за разные слова.
Значит до того, как слово будет добавлено в счетчик слов, нужно привести его к правильной форме.
Переходим ко второй части — написанию нормализатора английских слов.

Лемматизатор английских слов


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

Про лемматизацию уже было несколько статей на хабре, например вот и вот. Они используют базы aot. Мне не хотелось повторяться, а также было интересно поискать какие-нибудь другие базы для лемматизации. Я хотела бы рассказать про WordNet, на нем лемматизатор мы и построим. Начну с того, что на официальном сайте WordNet можно скачать исходники программы и сами базы данных. WordNet умеет очень много, но нам потребуется лишь малая часть его возможностей — нормализация слов.
Нам понадобятся только базы данных. В исходниках WordNet (на си) описан сам процесс нормализации, в сущности сам алгоритм я взяла оттуда, переписав на python. Ах да, разумеется для WordNet существует библиотека для python — nltk, но во-первых, она работает только на python 2.7, а во-вторых, насколько бегло я смотрела, при нормализации всего лишь посылаются запросы на сервер WordNet.
Общая диаграмма классов для лемматизатора:



Как видно из диаграммы, нормализуются только 4 части речи (существительные, глаголы, прилагательные и наречия).
Если кратко описать процесс нормализации, то он заключается в следующем:
1. Для каждой части речи загружаются из WordNet по 2 файла — индексный словарь (имеет название index и расширение согласно части речи, например index.adv для наречий) и файл исключений ( имеет расширение exc и название согласно части речи, например adv.exc для наречий).
2. При нормализации сперва проверяется массив исключений, если слово там есть, возвращается его нормализованная форма. Если слово не является исключением, то начинается привидение слова по грамматическим правилам, то есть отсекается окончание, приклеивается новое окончание, затем слово ищется в индексном массиве, и если оно там есть, то слово считается нормализованным. Иначе применяется следующее правило и тд, пока правила не закончатся или слово не будет нормализовано раньше.
Классы для леммализатора:
Базовый класс для частей речи BaseWordNetItem.py
# -*- coding: utf-8 -*- 

import os

class BaseWordNetItem:
	# Конструктор
	def __init__(self, pathWordNetDict, excFile, indexFile):
	
		self.rule=() # Правила замены окончаний при нормализации слова по правилам.
		
		self.wordNetExcDict={}  # Словарь исключений
		self.wordNetIndexDict=[] # Индексный массив	
		
		self.excFile = os.path.join(pathWordNetDict, excFile) # Получим путь до файла исключений	
		self.indexFile = os.path.join(pathWordNetDict, indexFile) # Получим путь до индексного словаря
		
		self.__ParseFile(self.excFile, self.__AppendExcDict) # Заполним словарь исключений
		self.__ParseFile(self.indexFile, self.__AppendIndexDict) # Заполним индексный массив 

		self.cacheWords={} # Немного оптимизации. Кэш для уже нормализованных слов, ключ - ненормализованное слово, значение - нормализованное слово	
		
			
			
	# Метод добавляет в словарь исключений одно значение. 
	# Файл исключений представлен в формате: [слово-исключение][пробел][лемма]	
	def __AppendExcDict(self, line):			
		# При разборе строки из файла, каждую строку разделяем на 2 слова и заносим слова в словарь(первое слово - ключ, второе - значение). При этом не забываем убрать с концов пробелы
		group = [item.strip() for item in line.replace("\n","").split(" ")]
		self.wordNetExcDict[group[0]] = group[1]

			
			
	# Метод добавляет в индексный массив одно значение.
	def __AppendIndexDict(self, line):			
		# На каждой строке берем только первое слово
		group = [item.strip() for item in line.split(" ")]
		self.wordNetIndexDict.append(group[0]) 
		

	# Метод открывает файл на чтение, читает по одной строке и вызывает для каждой строки функцию, переданную в аргументе
	def __ParseFile(self, file, contentHandler):	
		try:
			with open(file, 'r') as openFile: 
				for line in openFile:
					contentHandler(line)	# Для каждой строки вызываем обработчик контента
		except Exception as e:
			raise Exception('File does not load: "%s"' %file)	
			
			
	# Метод возвращает значение ключа в словаре. Если такого ключа в словаре нет, возвращается пустое значение. 
	# Под словарем здесь подразумевается просто структура данных 
	def _GetDictValue(self, dict, key):
		try:
			return dict[key]		
		except KeyError:
			return None
		
		
		
	# Метод проверяет слово на существование, и возвращает либо True, либо False.
	# Для того, чтобы понять, существует ли слово, проверяется индексный массив(там хранится весь список слов данной части речи).	
	def _IsDefined(self, word):
		if word in self.wordNetIndexDict:
			return True
		return False		
	
	
	
	# Метод возвращает лемму(нормализованную форму слова)			
	def GetLemma(self, word):
	
		word = word.strip().lower() 
	
		# Пустое слово возвращаем обратно
		if word == None:
			return None	

		# Пройдемся по кэшу, возможно слово уже нормализовывалось раньше и результат сохранился в кэше
		lemma = self._GetDictValue(self.cacheWords, word)
		if lemma != None:
			return lemma
			
		# Проверим, если слово уже в нормализованном виде, вернем его же
		if self._IsDefined(word):
			return word
			
			
		# Пройдемся по исключениям, если слово из исключений, вернем его нормализованную форму
		lemma = self._GetDictValue(self.wordNetExcDict, word)
		if lemma != None:
			return lemma
	
			
		# На этом шаге понимаем, что слово не является исключением и оно не нормализовано, значит начинаем нормализовывать его по правилам. 
		lemma = self._RuleNormalization(word)
		if lemma != None:
			self.cacheWords[word] = lemma # Предварительно добавим нормализованное слово в кэш
			return lemma		

		return None	
		
		
		
	# Нормализация слова по правилам (согласно грамматическим правилам, слово приводится к нормальной форме)
	def _RuleNormalization(self, word):
		# Бежим по всем правилам, смотрим совпадает ли окончание слова с каким либо правилом, если совпадает, то заменяем окончние.	
		for replGroup in self.rule:
			endWord = replGroup[0]			
			if word.endswith(endWord): 	
				lemma = word # Копируем во временную переменную
				lemma = lemma.rstrip(endWord) # Отрезаем старое окончание
				lemma += replGroup[1] # Приклеиваем новое окончание
				if self._IsDefined(lemma): # Проверим, что получившееся новое слово имеет право на существование, и если это так, то вернем его
					return lemma	
		return None


Класс для нормализации глаголов WordNetVerb.py
# -*- coding: utf-8 -*- 


from WordNet.BaseWordNetItem import BaseWordNetItem

# Класс для нормализации глаголов
# Класс наследуется от BaseWordNetItem

class WordNetVerb(BaseWordNetItem):
	def __init__(self, pathToWordNetDict):
	
		# Конструктор родителя (BaseWordNetItem)
		BaseWordNetItem.__init__(self, pathToWordNetDict, 'verb.exc', 'index.verb')


		# Правила замены окончаний при нормализации слова по правилам. К примеру, окончание "s" заменяется на "" , "ies" на и "y" тд.
		self.rule = (	
						["s"   , ""  ],
						["ies" , "y" ],
						["es"  , "e" ], 			
						["es"  , ""  ],	
						["ed"  , "e" ], 			
						["ed"  , ""  ],	
						["ing" , "e" ], 			
						["ing" , ""  ]	
					)

		# Метод получения нормализованной формы слова GetLemma(word) определен в базовом классе BaseWordNetItem


Класс для нормализации существительных WordNetNoun.py
# -*- coding: utf-8 -*- 


from WordNet.BaseWordNetItem import BaseWordNetItem

# Класс для работы с нормализацией существительных
# Класс наследуется от BaseWordNetItem

class WordNetNoun(BaseWordNetItem):
	def __init__(self, pathToWordNetDict):
	
		# Конструктор родителя (BaseWordNetItem)
		BaseWordNetItem.__init__(self, pathToWordNetDict, 'noun.exc', 'index.noun')
		
		# Правила замены окончаний при нормализации слова по правилам. К примеру, окончание "s" заменяется на "", "ses" заменяется на "s" и тд.
		self.rule = (	
						["s"    , ""    ],
						["’s"   , ""    ],
						["’"    , ""    ],							
						["ses"  , "s"   ],
						["xes"  , "x"   ], 			
						["zes"  , "z"   ],	
						["ches" , "ch"  ], 			
						["shes" , "sh"  ],
						["men"  , "man" ], 			
						["ies"  , "y"   ]					
					)	 
					
					
	# Метод возвращает лемму сушествительного(нормализованную форму слова)
	# Этот метод есть в базовом классе BaseWordNetItem, но нормализация существительных несколько отличается от нормализации других частей речи, 
	# поэтому метод в наследнике переопределен
	def GetLemma(self, word):	
		
		word = word.strip().lower() 
		
		# Если существительное слишком короткое, то к нормализованному виду мы его не приводим	
		if len(word) <= 2:
			return None	

		# Если существительное заканчивается на "ss", то к нормализованному виду мы его не приводим	
		if word.endswith("ss"):
			return None	
			
		# Пройдемся по кэшу, возможно слово уже нормализовывалось раньше и результат сохранился в кэше
		lemma = self._GetDictValue(self.cacheWords, word)
		if lemma != None:
			return lemma
			
		# Проверим, если слово уже в нормализованном виде, вернем его же
		if self._IsDefined(word):
			return word
		
		# Пройдемся по исключениям, если слово из исключений, вернем его нормализованную форму
		lemma = self._GetDictValue(self.wordNetExcDict, word)
		if (lemma != None):
			return lemma

			
		# Если существительное заканчивается на "ful", значит отбрасываем "ful", нормализуем оставшееся слово, а потом суффикс приклеиваем назад.
		# Таким образом, к примеру, из слова "spoonsful" после нормализации получится "spoonful"
		suff = ""
		if word.endswith("ful"): 
				word = word[:-3] # Отрезаем суффикс "ful"
				suff = "ful" # Отрезаем суффикс "ful", чтобы потом приклеить назад
		
		
		# На этом шаге понимаем, что слово не является исключением и оно не нормализовано, значит начинаем нормализовывать его по правилам. 
		lemma = self._RuleNormalization(word)
		if (lemma != None):
			lemma += suff # Не забываем добавить суффикс "ful", если он был
			self.cacheWords[word] = lemma # Предварительно добавим нормализованное слово в кэш
			return lemma		

		return None	
	


Класс для нормализации наречий WordNetAdverb.py
# -*- coding: utf-8 -*- 


from WordNet.BaseWordNetItem import BaseWordNetItem

# Класс для нормалзации наречий
# Класс наследуется от BaseWordNetItem

class WordNetAdverb(BaseWordNetItem):
	def __init__(self, pathToWordNetDict):
	
		# Конструктор родителя (BaseWordNetItem)
		BaseWordNetItem.__init__(self, pathToWordNetDict, 'adv.exc', 'index.adv')
		
		# У наречий есть только списки исключений(adv.exc) и итоговый список слов(index.adv).	
		# Правила замены окончаний при нормализации слова по правилам у наречий нет. 


Класс для нормализации прилагательных WordNetAdjective.py
# -*- coding: utf-8 -*- 

from WordNet.BaseWordNetItem import BaseWordNetItem

# Класс для работы с нормализацией прилагательных
# Класс наследуется от BaseWordNetItem

class WordNetAdjective(BaseWordNetItem):
	def __init__(self, pathToWordNetDict):
	
		# Конструктор родителя (BaseWordNetItem)
		BaseWordNetItem.__init__(self, pathToWordNetDict, 'adj.exc', 'index.adj')


		# Правила замены окончаний при нормализации слова по правилам. К примеру, окончание "er" заменяется на "" или  "e" и тд.
		self.rule = (	
						["er"  , "" ],
						["er"  , "e"],
						["est" , "" ], 			
						["est" , "e"]	
					)

						
		# Метод получения нормализованной формы слова GetLemma(word) определен в базовом классе BaseWordNetItem


Класс для лемматизатора Lemmatizer.py
# -*- coding: utf-8 -*- 


from WordNet.WordNetAdjective import WordNetAdjective
from WordNet.WordNetAdverb import WordNetAdverb
from WordNet.WordNetNoun import WordNetNoun
from WordNet.WordNetVerb import WordNetVerb

class Lemmatizer:
	def __init__(self, pathToWordNetDict):
	
		# Разделитель составных слов	
		self.splitter = "-" 						
		
		# Инициализируем объекты с частям речи
		adj = WordNetAdjective(pathToWordNetDict)	# Прилагательные
		noun = WordNetNoun(pathToWordNetDict)		# Существительные
		adverb = WordNetAdverb(pathToWordNetDict)	# Наречия
		verb = WordNetVerb(pathToWordNetDict)		# Глаголы
		
		self.wordNet = [verb, noun, adj, adverb]
		

	# Метод возвращает лемму слова (возможно, составного)		
	def GetLemma(self, word):
		# Если в слове есть тире, разделим слово на части, нормализуем каждую часть(каждое слово) по отдельности, а потом соединим
		wordArr = word.split(self.splitter)
		resultWord = []
		for word in wordArr:
			lemma = self.__GetLemmaWord(word)
			if (lemma != None):
				resultWord.append(lemma)
		if (resultWord != None):
			return self.splitter.join(resultWord)
		return None		
		
		
		
	# Метод возвращает лемму(нормализованную форму слова)			
	def __GetLemmaWord(self, word):
		for item in self.wordNet:
			lemma = item.GetLemma(word)
			if (lemma != None):
				return lemma
		return None		
				



Ну вот, с нормализацией закончили. Теперь частотный анализатор умеет нормализовывать слова. Переходим к последней части нашей задачи — получение переводов и транскрипции для английских слов.

Переводчик иностранных слов, использующий словари StarDict


Про StarDict можно писать долго, но основное преимущество этого формата то, что для него есть очень много словарных баз, практически на всех языках. На хабре еще не было статей на тему StarDict и пора восполнить это пробел. Файл, описывающий формат StarDict, обычно расположен рядом с самими исходниками.
Если отбросить все дополнения, то самый минимальный набор знаний по этому формату будет следующим:
Каждый словарь должен содержать в себе 3 обязательных файла:

1. Файл с расширением ifo — содержит непротиворечивое описание самого словаря;
2. Файл с расширением idx . Каждая запись внутри idx файла состоит из 3-х полей, идущих друг за другом:
  • word_str — Строка в формате utf-8, заканчивающаяся '\0';
  • word_data_offset -Смещение до записи в файле .dict (размер числа 32 или 64 бита);
  • word_data_size — Размер всей записи в файле .dict.

3. Файл с расширением dict — содержит сами переводы, добраться до которых можно зная смещение до перевода (смещение записано в файле idx ).

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



Классы для перводчика StarDict:
Базовый класс для элементов словаря BaseStarDictItem.py
# -*- coding: utf-8 -*- 


import os

class BaseStarDictItem:
	def __init__(self, pathToDict, exp):
	
		# Определяем переменную с кодировкой
		self.encoding = "utf-8"
		
		# Получаем полный путь до файла
		self.dictionaryFile = self.__PathToFileInDirByExp(pathToDict, exp)
		
		# Получаем размер файла
		self.realFileSize = os.path.getsize(self.dictionaryFile)	

	
	
	# Метод ищет в папке path первый попапвшийся файл с расширением exp 
	def __PathToFileInDirByExp(self, path, exp):
		if not os.path.exists(path):
			raise Exception('Path "%s" does not exists' % path)	
		
		end = '.%s'%(exp)
		list = [f for f in os.listdir(path) if f.endswith(end)]
		if list: 
			return os.path.join(path, list[0]) # Возвращаем первый попавшийся
		else:
			raise Exception('File does not exist: "*.%s"' % exp)	
			


Класс Ifo.py
# -*- coding: utf-8 -*- 


from StarDict.BaseStarDictItem import BaseStarDictItem
from Frequency.IniParser import IniParser

class Ifo(BaseStarDictItem):
	def __init__(self, pathToDict):
		
		# Конструктор родителя (BaseStarDictItem)
		BaseStarDictItem.__init__(self, pathToDict, 'ifo')	

		# Создаем и инициализируем парсер
		self.iniParser = IniParser(self.dictionaryFile)
		
		# Считаем из ifo файла параметры
		# Если хотя бы одно из обязательных полей отсутствует, вызовется исключение и словарь не будет загружен
		self.bookName = self.__getParameterValue("bookname", None) # Название словаря [Обязательное поле]
		self.wordCount = self.__getParameterValue("wordcount", None)  # Количество слов в ".idx" файле [Обязательное поле]
		self.synWordCount = self.__getParameterValue("synwordcount", "")  # Количество слов в ".syn" файле синонимов [Обязательное поле, если есть файл ".syn"]
		self.idxFileSize = self.__getParameterValue("idxfilesize", None) # Размер (в байтах) ".idx" файла. Если файл сжат архиватором, то здесь указывается размер исходного несжатого файла [Обязательное поле]
		self.idxOffsetBits = self.__getParameterValue("idxoffsetbits", 32)  # Размер числа в битах(32 или 64), содержащего внутри себя смещение до записи в файле .dict. Поле пояилось начиная с версии 3.0.0, до этого оно всегда было 32 [Необязательное поле]
		self.author = self.__getParameterValue("author", "") # Автор словаря [Необязательное поле]
		self.email = self.__getParameterValue("email", "") # Почта [Необязательное поле]
		self.description = self.__getParameterValue("description", "") # Описание словаря [Необязательное поле]
		self.date = self.__getParameterValue("date", "") # Дата создания словаря [Необязательное поле]
		self.sameTypeSequence = self.__getParameterValue("sametypesequence", None) # Маркер, определяющий форматирование словарной статьи[Обязательное поле]
		self.dictType = self.__getParameterValue("dicttype", "") # Параметр используется некоторыми словарными плагинами, например WordNet[Необязательное поле]			
	

	def __getParameterValue(self, key, defaultValue):
		try:
			return self.iniParser.GetValue(key) 
		except:
			if defaultValue != None:
				return defaultValue
			raise Exception('\n"%s" has invalid format (missing parameter: "%s")' % (self.dictionaryFile, key))	


Класс Idx.py
# -*- coding: utf-8 -*- 


from struct import unpack
from StarDict.BaseStarDictItem import BaseStarDictItem


class Idx(BaseStarDictItem):

	# Конструктор
	def __init__(self, pathToDict, wordCount, idxFileSize, idxOffsetBits):

		# Конструктор родителя (BaseStarDictItem)
		BaseStarDictItem.__init__(self, pathToDict, 'idx')
		
		self.idxDict ={} # Словарь, self.idxDict = {'иностр.слово': [Смещение_до_записи_в_файле_dict, Размер_всей_записи_в_файле_dict], ...}	
		self.idxFileSize = int(idxFileSize) # Размер файла .idx, записанный в .ifo файле
		self.idxOffsetBytes = int(idxOffsetBits/8) # Размер числа, содержащего внутри себя смещение до записи в файле .dict. Переводим в байты и приводим к числу
		self.wordCount = int(wordCount) # Количество слов в ".idx" файле
		
		# Проверяем целостность словаря (информация в .ifo файле о размере .idx файла [idxfilesize] должна совпадать с его реальным размером)
		self.__CheckRealFileSize()
		
		# Заполняем словарь self.idxDict данными из файла .idx
		self.__FillIdxDict()
	
		# Проверяем целостность словаря (информация в .ifo файле о количестве слов [wordcount] должна совпадать с реальным количеством записей в .idx файле)
		self.__CheckRealWordCount()
	
	
	# Функция сверяет размер файла, записанный в .ifo файле, с ее реальным размером и в случае расхождений генерирует исключение	
	def __CheckRealFileSize(self):
		if self.realFileSize != self.idxFileSize:
			raise Exception('size of the "%s" is incorrect' %self.dictionaryFile)

			
	# Функция сверяет количестве слов, записанное в .ifo файле, с реальным количеством записей в файле .idx и в случае расхождений генерирует исключение			
	def __CheckRealWordCount(self):
		realWordCount = len(self.idxDict)
		if realWordCount != self.wordCount:
			raise Exception('word count of the "%s" is incorrect' %self.dictionaryFile)
	

	# Функция считывает из потока данных массив байтов заданной длины, затем преобазует байткод в число	
	def __getIntFromByteArray(self, sizeInt, stream):
		byteArray = stream.read(sizeInt) # Получили массив байтов, отведенных под число
		
		# Определим формат пробразования в числовой формат 
		formatCharacter = 'L'   # Формат означает "unsigned long" (для sizeInt = 4)
		if sizeInt == 8:
			formatCharacter = 'Q' # Формат означает "unsigned long long" (для sizeInt = 8)
		format = '>' + formatCharacter # Общий формат будет состоять из: "направление порядка байтов" + "формат числа"
		# Строка '>' - означает, что мы распаковываем байткод в число int(размера formatCharacter) от старшего бита к младшему.
		
		integer = (unpack(format, byteArray))[0] # Распаковываем массив байтов в заданном формате	
		return int(integer) 
		
		

	# Функция разделяет файл .idx на отдельные записи (запись состоит из 3-х полей) и каждую запись добавляет в словарь self.idxDict
	def __FillIdxDict(self):
		languageWord = ""
		with open(self.dictionaryFile, 'rb') as stream:
			while True:
				byte = stream.read(1)  # Читаем один байт
				if not byte: break # Если байтов больше нет, то выходим из цикла
				if byte != b'\0':	 # Если байт не является символом окончания строки '\0', то прибавляем его к слову
					languageWord += byte.decode("utf-8")
				else: 
					# Если дошли до '\0', то считаем, что слово закончилось и дальше идут два числа ("Смещение до записи в файле dict" и "Размер всей записи в файле dict")
					wordDataOffset = self.__getIntFromByteArray(self.idxOffsetBytes, stream)  # Получили первое число "Смещение до записи в файле dict"
					wordDataSize = self.__getIntFromByteArray(4, stream) # Получили второе число "Размер всей записи в файле dict"

					self.idxDict[languageWord] = [wordDataOffset, wordDataSize] # Добавим в словарь self.idxDict запись: иностранное слово + смещение + размер данных
					languageWord = "" # Обнуляем переменную, поскольку начинается следующая струтура
			

			
	# Функция возвращает расположение слова в файле .dict ("Смещение до записи в файле dict" и "Размер всей записи в файле dict").
	# Если такого слова в словаре нет, функция возвращает None
	def GetLocationWord(self, word):			
		try:
			return self.idxDict[word]		
		except KeyError:
			return [None, None]	


Класс Dict.py
# -*- coding: utf-8 -*- 


from StarDict.BaseStarDictItem import BaseStarDictItem

# Маркер может быть составным (к примеру, sametypesequence = tm).
# Виды одно-символьныx идентификаторов  словарных статей (для всех строчных идентификаторов текст в формате utf-8, заканчивается '\0'):
# 'm' - просто текст в кодировке utf-8, заканчивается '\0' 
# 'l' - просто текст в НЕ в кодировке utf-8, заканчивается '\0' 
# 'g' - текст размечен с помощью языка разметки текста Pango
# 't' - транскрипция в кодировке utf-8, заканчивается '\0' 
# 'x' - текст в кодировке utf-8, размечен с помощью xdxf
# 'y' - текст в кодировке utf-8, содержит китайские(YinBiao) или японские (KANA) символы 
# 'k' - текст в кодировке utf-8, размечен с помощью  KingSoft PowerWord XML 
# 'w' - текст размечен с помощью  MediaWiki
# 'h' - текст размечен с помощью  Html
# 'n' - текст размечен формате для WordNet
# 'r' - текст содержит список ресурсов. Ресурсами могут быть файлы картинки (jpg), звуковые (wav), видео (avi), вложенные(bin) файлы и др.
# 'W' - wav файл
# 'P' - картинка
# 'X' - этот тип зарезервирован для экспериментальных расширений



class Dict(BaseStarDictItem):
	def __init__(self, pathToDict, sameTypeSequence):

		# Конструктор родителя (BaseStarDictItem)
		BaseStarDictItem.__init__(self, pathToDict, 'dict')
	
		# Маркер, определяющий форматирование словарной статьи
		self.sameTypeSequence = sameTypeSequence 
		

			
	def	GetTranslation(self, wordDataOffset, wordDataSize):
		try:
			# Убеждаемся что смещение и размер данных неотрицательны и находятся в пределах размера файла .dict
			self.__CheckValidArguments(wordDataOffset, wordDataSize)

			# Открываем файл .dict как бинарный
			with open(self.dictionaryFile, 'rb') as file:  # менеджер контекста
				file.seek(wordDataOffset) # Смешаемся внутри файла до начала текста, относящегося к переводу слова
				byteArray = file.read(wordDataSize) # Читаем часть файла, относящегося к переводу слова
				return byteArray.decode(self.encoding) # Вернем раскодированный в юникодную строку набор байтoв (self.encoding определен в базовом классе BaseDictionaryItem)
	
		except Exception:
			return None	

	
	

	def	__CheckValidArguments(self, wordDataOffset, wordDataSize):	
		if wordDataOffset is None:
			pass
		if wordDataOffset < 0:
			pass
		endDataSize = wordDataOffset + wordDataSize
		if wordDataOffset < 0 or wordDataSize < 0 or endDataSize > self.realFileSize:
			raise Exception



Ну вот, переводчик готов. Теперь нам осталось только объединить вместе частотный анализатор, нормализатор слов и переводчик. Создадим главный файл main.py и файл настроек Settings.ini.
Главный файл main.py
# -*- coding: utf-8 -*- 

import os
import xlwt3 as xlwt

from Frequency.IniParser import IniParser
from Frequency.FrequencyDict import FrequencyDict
from StarDict.StarDict import StarDict

ConfigFileName="Settings.ini"

class Main:
	def __init__(self):
	
		self.listLanguageDict = [] # В этом массиве сохраним словари StarDict
		self.result = [] # В этом массиве сохраним результат (само слово, частота, его перевод)

		try:
			# Создаем и инициализируем конфиг-парсер
			config = IniParser(ConfigFileName)	

			self.pathToBooks = config.GetValue("PathToBooks") # Считываем из ini файла переменную PathToBooks, которая содержит  путь до файлов(книг, документов и тд), из которых будут браться слова		
			self.pathResult = config.GetValue("PathToResult") # Считываем из ini файла переменную PathToResult, которая содержит путь для сохранения результата
			self.countWord = config.GetValue("CountWord") # Считываем из ini файла переменную CountWord, которая содержит количество первых слов частотного словаря, которые нужно получить
			self.pathToWordNetDict = config.GetValue("PathToWordNetDict") # Считываем из ini файла переменную PathToWordNetDict, которая содержит путь до словаря WordNet
			self.pathToStarDict = config.GetValue("PathToStarDict") # Считываем из ini файла переменную PathToStarDict, которая содержит путь до словарей в формате StarDict	
			
			# Отделяем пути словарей StarDict друг от друга и удаляем пробелы с начала и конца пути. Все пути заносим в список listPathToStarDict
			listPathToStarDict = [item.strip() for item in self.pathToStarDict.split(";")]

			# Для каждого из путей до словарей StarDict создаем свой языковый словарь
			for path in listPathToStarDict:
				languageDict = StarDict(path)
				self.listLanguageDict.append(languageDict) 
			
			# Получаем список книг, из которых будем получать слова
			self.listBooks = self.__GetAllFiles(self.pathToBooks)

			# Создаем частотный словарь		
			self.frequencyDict = FrequencyDict(self.pathToWordNetDict)			
	
			# Подготовка закончена, загружены словари StarDict и WordNet. Запускаем задачу на выполнение, то есть начинаем парсить текстовые файл, нормализовывать и считать слова			
			self.__Run()
		
		except Exception as e:
			print('Error: "%s"' %e)


	# Метод создает список файлов, расположенных в папке path	
	def __GetAllFiles(self, path):
		try:
			return [os.path.join(path, file) for file in os.listdir(path)]
		except Exception:
			raise Exception('Path "%s" does not exists' % path)		

		
	# Метод бежит по всем словарям, и возвращает перевод из ближайшего словаря. Если перевода нет ни в одном из словарей, возвращается пустая строка	
	def __GetTranslate(self, word):
		valueWord = ""
		for dict in self.listLanguageDict:
			valueWord = dict.Translate(word)
			if valueWord != "":
				return valueWord
		return valueWord
		
		
		
	# Метод сохраняет результат(само слово, частота, его перевод) по первым countWord словам в файл формата Excel  	
	def __SaveResultToExcel(self):	
		try:
			if not os.path.exists(self.pathResult):
				raise Exception('No such directory: "%s"' %self.pathResult)	
			
			if self.result:	
				description = 'Frequency Dictionary'
				style = xlwt.easyxf('font: name Times New Roman')			
				wb = xlwt.Workbook()
				ws = wb.add_sheet(description + ' ' + self.countWord)	
				nRow = 0
				for item in self.result:
					ws.write(nRow, 0, item[0], style)
					ws.write(nRow, 1, item[1], style)
					ws.write(nRow, 2, item[2], style)
					nRow +=1			
				wb.save(os.path.join(self.pathResult, description +'.xls'))
		except Exception as e:
			print(e)			
	
	
	
	# Метод запускает задачу на выполнение
	def __Run(self):					
		# Отдаем частотному словарю по одной книге	
		for book in self.listBooks:
			self.frequencyDict.ParseBook(book)		
			
		# Получаем первые countWord слов из всего получившегося списка английских слов			
		mostCommonElements = self.frequencyDict.FindMostCommonElements(self.countWord)
		
		# Получаем переводы для всех слов
		for item in mostCommonElements:
			word = item[0]
			counterWord = item[1]
			valueWord = self.__GetTranslate(word)
			self.result.append([counterWord, word, valueWord])	

		# Запишем результат в файл формата Excel 
		self.__SaveResultToExcel()		


if __name__ == "__main__":
	main = Main()


Файл настроек Settings.ini
; Путь до файлов(книг, документов и тд), из которых будут браться слова
PathToBooks = e:\Bienne\Frequency\Books

; Путь до словаря WordNet(он нужен для нормализации слов)
PathToWordNetDict = e:\Bienne\Frequency\WordNet\wn3.1.dict\

; Путь до словарей в формате StarDict(нужны для перевода слов)
PathToStarDict = e:\Bienne\Frequency\Dict\stardict-comn_dictd04_korolew

; Количество первых слов частотного словаря, которые будут записаны в файл в формате Excel
CountWord = 100

; Путь, куда сохранить результат (файл в формате Excel с тремя заполненными колонками - само слово, частота, его перевод)
PathToResult = e:\Bienne\Frequency\Books



Единственной сторонней библиотекой, которую нужно скачать и поставить дополнительно, является xlwt, она потребуется для создания файла в формате Excel (туда записывается результат).
В файле настроек Settings.ini для переменной PathToStarDict можно писать несколько словарей через ";". В этом случае слова будут искаться в порядке очередности словарей — если слово найдено в первом словаре, поиск заканчивается, иначе перебираются все остальные словари StarDict.

Послесловие


Все исходники, описанные в этой статье, можно скачать на github.
Напоминание:
  1. Скрипты писались под windows;
  2. Использовался python 3.3;
  3. Дополнительно нужно будет поставить библиотеку xlwt для работы с Excel;
  4. Отдельно нужно скачать словарные базы для WordNet и StarDict (у словарей StarDict нужно будет дополнительно распаковать запакованные в архив файлы с расширением dict);
  5. В файле Settings.ini нужно прописать пути для словарей и куда сохранить результат.
  6. Отдельно хотелось бы сказать про транскрипцию, она есть не во всех словарных базах StarDict, но найти словарь с транскрипцией по поиску в гугле не составит труда (во всяком случае я их легко находила).
Метки:
Поделиться публикацией
Реклама помогает поддерживать и развивать наши сервисы

Подробнее
Реклама
Комментарии 24
  • 0
    Если уж заморачиваться с кэшем, то я бы лично заглянул в cntlst, а то собирать кэш по уже обработанным словам — только память жрать (и потакать плохим стилистам :-)).
    • 0
      Простите, но я не совсем поняла что такое cntlst
      • 0
        Если распаковать словари, в папке dict (рядом с index.* и *.exc) лежит файл с частотностью словоупотреблений. Называется cntlist:
        $ ls dict
        adj.exc  cntlist      cousin.exc  data.adv   data.verb  index.adj  index.noun   index.verb     noun.exc     sents.vrb  verb.Framestext
        adv.exc  cntlist.rev  data.adj    data.noun  dbfiles    index.adv  index.sense  log.grind.3.1  sentidx.vrb  verb.exc
        
        • 0
          Я посмотрела этот файл — там идут три параметра на каждой строке. Первый параметр — количество повторений в семантически связанных текстах, второй параметр — смысловой ключ и третий параметр — смысловое число. Нам, получается первый и последний параметры неинтересны, остается только второй, он содержит лемму + часть речи, закодированную в число+другие ключи. Как все это можно применить именно к кэшу?
          • +1
            Просто при старте добавлять в кэш первые 100 позиций из этого файла :-). Это мне кажется более осмысленным, чем хранить кэш по обработанным словам.
            • 0
              Спасибо за совет:)
    • +6
      Рискну показаться сексистом, но респект девушке-программисту за хорошую статью!

      В нашей отрасли девушек, увы, не хватает…
      • 0
        Подскажите, а то не совсем понятно чем можно *.dict.dz распаковать. Архиватор «dzip» ругается на неправильный формат архива.
      • +2
        Наверное, питон не является для Вас основным языком.
        Я сам не программирую на нем, но и мне заметно, что стиль написание значительно ближе к Java/C#/PHP чем к питону
        • +1
          С++, начинающий уровень
        • 0
          А нет top-1000 слов на основе ваших книг? Думаю, они везде будут примерно одинаковы. Хотелось бы посмотреть на готовый файлик в excel-формате, а лучше в google docs.
          • 0
            Топ 1000 не будет одинаковым, он существенно будет зависеть от распарсенного материала. На самом деле RetroGuy написал постом ниже, слова это просто слова, и их действительно сложно учить без контекста. Я создавала свои скрипты не для поиска наиболее часто встречающихся слов. Мне нужно было получить значение все слов из конкретных текстов, которые мне задавал читать мой преподаватель английского. Но если бы я задалась целью учить слова по частоте, я бы взяла базы отсюда, там есть корпус из 5,000 лемм. Вот, пожалуй их бы перевела с помощью StarDict. Но задач таких я никогда не ставила. Когда я писала статью, я хотела рассказать прежде всего о WordNet и StarDict, а частотный словарь это так, поиграться…
          • 0
            Кода-то сам делал нечто подобное. Решил всеръез занятся английским, начал читать книги. А потом думаю, а почему бы мне не вычислить самые частоиспользуемые слова, чтобы учить только их? Дальше мысль развилась, и я решил взять десяток книг, вычислить пересечение множеств слов этих книг, а потом отсортировать по упорядоченности. Открывая результирующий файлик, я с радостью потирал руких — хаха, сейчас я выучу все эти слова и буду понимать английскую речь…

            Я сильно разочаровался, т.к в итоге увидел ряд из тысячи слов типа tree, box, house, book, desk, wall, dog, can, bad, good, get…

            Потом, через некоторое время я осознал, что такой «автоматизированный» способ выборки слов не сильно помогает в освоении языка. Слова надо запоминать непосредственно во фразах, по контексту, в сочетании с другими словами и общей структурой предложения. Я уж не говорю о разных значениях одних и тех же слов :) Яркий пример — словосочетание secure a button, которое переводится как застегнуть пуговицу. Если бы я встретил эти слова в автоматической выборке, даже не обратил бы на них внимание. А в тексте я не сразу понял, почему это вдруг главный герой вдруг ни с того ни с сего решил защищать кнопку, после того как надел пальто. Пришлось разобраться. :)

            В общем, немало тогда шишек набил. Соберусь как-нибудь, напишу статью :)
            • +1
              Согласен. Мой опыт тоже говорит: читать, читать и ещё раз читать. Желательно с толковым словарём (En->En).
            • 0
              Немного замечаний:
              * в nltk wordnet работает без обращения к серверам;
              * в nltk код из ветки 2and3 под Python3.3 работает (не все, но wordnet работает);
              * у collections.Counter есть метод most_common;
              * в aot для английского языка лемматизация работает тоже на основе wordnet (только словари упакованы в формат, который позволяет держать все в памяти и делать быстрые выборки).
              • 0
                Спасибо за замечания. по правде сказать, я думала, что aot использует свои базы, а он оказывается перегоняет в свой формат WordNet. Понятно, будем знать, что все пути ведут к WordNet. Про метод most_common я знала, но мне хотелось сортировать и по ключу и по значению, и поэтому я и использовала двойную сортировку. Про nltk если честно, я не стала разбираться, как я в статье написала, я просто посмотрела исходники самого WordNet, там простой и алгоритм и правила.
              • 0
                Верно ли я понял, что слово commander-in-chief не будет правильно нормализовано описанной выше версией нормализатора из-за наличия предлога in, который не будет нормализован?
                • 0
                  Поняли не совсем верно. Составное слово, в котором есть тире, тоже нормализуется. Если в слове есть тире, слово делится на части, нормализуется каждое слово по отдельности, а потом опять соединяется через тире. То есть в слове commander-in-chief будут нормализованы по отдельности три слова (commander, in, chief )
                  Посмотрите файл Lemmatizer.py, там есть метод, в котором все это и происходит:
                  def GetLemma(self, word)
                  • 0
                    GetLemma как я понял вызывается последовательно для [verb, noun, adj, adverb]. Предлогов среди них нет и GetLemma для «in» вернет None. Нет?
                    • 0
                      Есть. Я подебажила немного, при нормализации слова «in» оно найдется в индексном словаре ворднета index.adj
                      • 0
                        Занятно, что предлог записали в прилагательные. Ясно, буду знать.
                • +1
                  Кстати, при чтении idx файла нельзя вызывать decode(«utf-8») для каждого байта, иначе декодер вываливается с ошибкой на первом же мультибайтном символе. Нужно побайтно прочитать всю последовательность до '\0' в бинарную строку и только затем вызвать decode для всей строки.
                  В остальном спасибо за статью, очень приятно было читать ваш код.
                  • 0
                    Спасибо за совет про кодировку, теперь в следующий раз точно про это не забуду.

                  Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.