【Python】検索機能付きの英単語帳を作ってみた【Tkinter】

# はじめに
Pythonで検索機能が付いた英単語帳を作ってみました。
英単語を検索すると自動的に意味と検索回数が記録されます。
![検索ウィンドウ.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1495498/059effe3-2cb8-cbf2-0586-efc28b857854.gif)
![ビューウィンドウ.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1495498/34ced043-7e01-d20c-c7f3-a842d7f4a5a4.gif)

# 説明書
各ウインドウの説明になります。

## 検索ウインドウ
![検索ウインドウ.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1495498/63a25875-1668-aa9f-dab2-a2994137bef3.png)

## 英単語帳ウインドウ
![単語帳ウインドウ.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1495498/939ca509-cda8-e662-77d8-6814a5a0cc1d.png)


# 背景
普段海外ゲーム([CSGO](https://store.steampowered.com/app/730/CounterStrike_Global_Offensive/?l=japanese)とか[Path of Exile](https://store.steampowered.com/app/238960/Path_of_Exile/))をやる時に、分からない英単語を[weblio](https://ejje.weblio.jp/)で調べているんですが、「この単語何回も調べてる気がするな」→「単語覚えないと」→「英単語帳アプリで覚えるか」→「意味わざわざ書くの面倒だな」→「調べたら自動で意味登録できたら良いのに」という思考プロセスで作ろうと思いました。
~~weblioに英単語登録機能という似たようなものがありますが、「自動意味登録」と「検索回数」という点で差別化できていると信じています。~~

# システム
大まかなシステムは以下の図のようになっています。
![システム.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1495498/5cdf17d0-7c34-8e7d-0b13-2872923bca2b.png)

# 環境
- windows 11
- Python 3.8.5

# ソースコード
Githubに今回作成したプログラム全体のソースコードを載せているので、[こちら](https://github.com/kaito11201/WordBook_with_Search)でダウンロードして動かして頂けると幸いです。
ファイル構成は以下のようになります。

~~~
.
├── main.py
├── search.py
├── app.py
├── materials
│   └── search.png
└── wordbook
    └── list_en.csv
~~~

以下に主な部分のソースコードを載せておきます。

## 検索
この検索クラスでは主に以下の役割があります。
- **weblioで英単語を検索（スクレイピング）**
- **英単語帳を更新**
- 英単語帳ファイル(csv)を取得
- 検索ワードを取得
- 過去に検索したことがあるか確認
- 単語帳に含まれている英単語の「意味」と「検索回数」を渡す
- 「検索回数」を増やす

以下が検索クラスのソースコードになります。

~~~python:search.py
# -*- coding: utf-8 -*-
import pandas as pd
import requests
from bs4 import BeautifulSoup
import mojimoji as moji

# 検索エンジン名 <class 'str'>
SEARCH_ENGINE = 'https://ejje.weblio.jp/content/'

# 表示する意味の個数 <class 'int'>
MEAN_N = 3

class Search:
    # 英単語を検索・単語帳に英単語を追加するクラス
    
    def __init__(self):
        # 読み込む単語帳の名前(ファイルパス) <class 'str'>
        self.name = None
        
        # 読み込んだ単語帳をデータフレームで格納 <class 'pandas.core.frame.DataFrame'>
        self.df = None
        
        # 検索する英単語 <class 'str'>
        self.word = None
    
    def set_word(self, word):
        # 検索する英単語を格納する関数
        # 引数: word <class 'str'>
        self.word = word
        
    def set_csv(self, name):
        # 単語帳を格納する関数
        # デフォルトで'list_en.csv'を取得する
        # 引数: name <class 'str'>
        
        self.name = name
        
        # 単語帳をを読み取る
        self.df = pd.read_csv(self.name)
        self.df = self.df.set_index('word')
    
    def is_searched(self):
        # 検索した英単語が過去に検索したか確認する関数
        # 戻り値: <class 'bool'>
        
        if self.word in self.df.index.values:
            return True
        return False
    
    def get_mean_count(self):
        # 単語帳に保存されている、検索された英単語の「意味」と「検索回数」を渡す関数
        # 戻り値: mean <class 'str'>, count <class 'str'>
        
        mean = self.df.at[self.word, 'mean']
        count = f"\n※この単語は{moji.han_to_zen(str(self.df.at[self.word, 'count']))}回検索しています。"
        return mean, count
    
    def add_count(self):
        # 「検索回数」を増やす関数
        self.df.at[self.word, 'count'] += 1
        
    def search(self):
        # 英単語を検索・意味を表示する関数
        # 戻り値: mean <class 'str'> or error_msg <class 'str'>
        
        # urlを検索・スクレイピング
        try:
            search_word = SEARCH_ENGINE + self.word
            url = requests.get(search_word)
        except:
            error_msg = "何らかの問題で検索することが出来ません。ネットに繋がっているか確認してください。"
            return error_msg
        
        try:
            soup = BeautifulSoup(url.text, "html.parser")
            
            # 「、」区切りでMEAN_N(3つ)まで意味を取得
            mean = soup.find(class_='content-explanation ej').get_text().strip().split('、')[:MEAN_N]
            mean = '、'.join(mean)
            
            # 英単語をCSVに追加
            self.df.loc[self.word] = [1, mean]
        
        except:
            error_msg = "入力された英単語は存在しません。"
            return error_msg
        
        return mean
    
    def update(self):
        # 単語帳を更新する関数
        self.df.to_csv(self.name)
~~~

## GUI
GUIにはTkinterというGUIライブラリを用いました。
他にもPySimpleGUIやKivyなどもあったのですが、1番情報量が多そうなTkinterを選択しました。
### アプリケーションクラス
今回作成したプログラムの制御を担うクラスで、基本ウィンドウになります。
![検索ウィンドウ.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1495498/059effe3-2cb8-cbf2-0586-efc28b857854.gif)
このクラスの主な役割は以下になります。
- **プログラムの制御**
- 検索ウィンドウの作成
- メニューバーの作成
- 検索欄の作成
- 検索ボタンの作成
- 結果欄の作成

以下がアプリケーションクラスのソースコードになります。

~~~python:App
class App:
    # GUIを表示するクラス
    
    def __init__(self):
        # 検索ウィンドウ
        self.root = tk.Tk()
        
        # 検索ウィンドウのウィジェット作成
        self._create_window()
        
        # 検索ウィンドウのメニューバー作成
        self._create_menu()
        
        # 検索ウィンドウのメインフレーム作成
        self._create_main_frame()
        
        # 検索機能を実装
        self.search = Search()
        
    def _create_window(self):
        # 検索ウィンドウの設定をする関数
        
        # ウィンドウタイトルを決定
        self.root.title("調べる英単語帳")

        # ウィンドウの大きさを決定
        self.root.geometry(MAIN_WINDOW_SIZE)

        # ウィンドウのグリッドを 1x1 にする
        self.root.grid_rowconfigure(0, weight=1)
        self.root.grid_columnconfigure(0, weight=1)
        
    def _create_menu(self):
        # メニューバーを作成する関数
        
        # メニューバーの作成
        menu = tk.Menu()
        self.root.config(menu=menu)
        
        # ファイルバーの作成
        menu_file = tk.Menu()
        menu.add_cascade(label='ファイル', menu=menu_file)
        menu_file.add_command(label='終了', command=lambda:self._close())
        
        # 表示バーの作成
        menu_display = tk.Menu()
        menu.add_cascade(label='表示', menu=menu_display)
        menu_display.add_command(label='英単語一覧', command=lambda:self._open_viewer())

    def _create_main_frame(self):
        # 検索ウィンドウのウィジェットを作成する関数
        
        # メインページフレーム作成
        self.main_frame = tk.Frame()
        self.main_frame.pack(fill=tk.X)
        
        # 検索テキストボックス
        self.search_box = ttk.Entry(self.main_frame, font=('Constantia', '20'))
        self.search_box.grid(row=0, column=0, sticky=tk.EW, padx=5, pady=10)
        self.search_box.bind('<Return>', self._search)
        
        # 検索画像ボタン
        self.img = tk.PhotoImage(file='materials/search.png').subsample(8,8)
        self.search_button = tk.Button(self.main_frame, image=self.img, relief='flat', command=lambda:self._search())
        self.search_button.grid(row=0, column=1, pady=5)
        
        # 結果テキストボックス
        self.result_box = tk.Text(self.main_frame, width=47, height=6, font=('Constantia', '10'))
        self.result_box.grid(row=1, column=0, columnspan=2, sticky=tk.W, padx=5)
        # 縦方向のスクロールバーの追加
        ybar = tk.Scrollbar(self.main_frame, orient=tk.VERTICAL)
        ybar.grid(row=1, column=1, sticky=tk.N+tk.S+tk.E)
        ybar.config(command=self.result_box.yview)
        self.result_box.config(yscrollcommand=ybar.set)

    def _search(self, event='<Return>'):
        # 検索を行う関数
        # 引数: event <class 'str'>
        
        # 検索した英単語の意味 <class 'str'>
        mean = ""
        
        # 検索した英単語のこれまでの表示回数 <class 'str'>
        count = ""
        
        # 結果テキストボックスに文字が含まれていたときクリアする
        if self.result_box.get('1.0', tk.END):
            self.result_box.delete('1.0', tk.END)
        
        # 検索ボックスに入力された文字を取得 <class 'str'>
        word = self.search_box.get()
        
        # 検索する英単語を渡す
        self.search.set_word(word)
        
        # 単語帳を渡す(デフォルトで'list_en.csv')
        self.search.set_csv(PATH + WORDS_NAME)
        
        # 検索する英単語が過去に検索したか確認
        if self.search.is_searched():
            # 意味と検索回数を取得し、検索回数を1追加する
            mean, count = self.search.get_mean_count()
            self.search.add_count()
        
        else:
            # 検索して意味を取得
            mean = self.search.search()
        
        # 結果ボックスに意味と検索回数を格納
        self.result_box.insert('1.0', mean)
        self.result_box.insert(tk.END, count)
        
        # 単語帳を更新
        self.search.update()
    
    def _open_viewer(self):
        # 単語帳の内容を表示する関数
        Viewer(self.root, PATH + WORDS_NAME)
        
    def _close(self):
        # アプリを終了する関数
            self.root.destroy()
~~~

### ビューウインドウクラス
このクラスは英単語帳(csv)の中身を表示するクラスになります。
![ビューウィンドウ.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1495498/34ced043-7e01-d20c-c7f3-a842d7f4a5a4.gif)
主な機能は以下になります。
- **英単語帳(csv)の表示**
- **英単語の並び替え**
- ビューウインドウの作成

以下がビューウインドウクラスのソースコードになります。

~~~python:Viewer
class Viewer:
    # 単語帳の内容を表示するクラス
    
    def __init__(self, root, name):
        # ビューウィンドウ
        self.root = tk.Toplevel(root)
        
        # 単語帳の読み込み
        self.df = pd.read_csv(name)
        
        # 単語帳の内容を表示するテーブル
        self.tree = None
        
        # ソートの切り替え
        self.is_accending = [True, True, True]
        
        # ビューウィンドウを作成
        self._create_window()
        
        # テーブルの作成
        self._create_tree_view()
        
        # 単語帳の内容を表示
        self._show_words()

    def _create_window(self):
        # ビューウィンドウを作成する関数
        
        self.root.title =('英単語一覧')
        self.root.geometry(VIEWER_WINDOW_SIZE)
        
        # ウィンドウのグリッドを 1x1 にする
        self.root.grid_rowconfigure(0, weight=1)
        self.root.grid_columnconfigure(0, weight=1)
        
    def _create_tree_view(self):
        # テーブルを作成する関数
        
        # フレームの作成
        frame = tk.Frame(self.root)
        frame.pack(fill=tk.BOTH, expand=True, padx=5, pady=5)
        frame.columnconfigure(0, weight=1)
        frame.rowconfigure(0, weight=1)
        
        # テーブルの作成
        self.tree = ttk.Treeview(frame)
        self.tree.column('#0', width=50, stretch=tk.NO, anchor=tk.E)
        self.tree.grid(row=0, column=0, sticky=tk.W + tk.E + tk.N + tk.S)

        # 縦方向のスクロールバーを追加
        vscrollbar = ttk.Scrollbar(frame, orient=tk.VERTICAL, command=self.tree.yview)
        self.tree.configure(yscrollcommand=vscrollbar.set)
        vscrollbar.grid(row=0, column=1, sticky=tk.W + tk.E + tk.N + tk.S)
        
        # 列を３列作る
        self.tree["column"] = (1, 2, 3)
        self.tree["show"] = "headings"
        
        # ヘッダーテキスト
        self.tree.heading(1, text="英単語", command=lambda:self._sort(COLUMN_NAME[0]))
        self.tree.heading(2, text="検索回数", command=lambda:self._sort(COLUMN_NAME[1]))
        self.tree.heading(3, text="意味", command=lambda:self._sort(COLUMN_NAME[2]))
        
        # 列の幅
        self.tree.column(1, width=100)
        self.tree.column(2, width=50)
        self.tree.column(3, width=300)
    
    def _show_words(self):
        # 単語帳の内容を表示する関数
        
        # 内容をクリア
        self.tree.delete(*self.tree.get_children())
        
        # データ挿入
        for i in self.df.index:
            self.tree.insert("", "end", values=(self.df['word'][i], self.df['count'][i], self.df['mean'][i]))
        
    def _sort(self, col_name):
        # 単語帳にある英単語を並び替える関数
        # 引数: col_name <class 'dict'>
        
        # 列名からキーを取得
        key = [k for k, v in COLUMN_NAME.items() if v == col_name][0]
        
        # 英単語を並び替え
        self.df = self.df.sort_values(col_name, ascending=self.is_accending[key])
        
        # ソート方法の切り替え
        if self.is_accending[key]:
            self.is_accending[key] = False
        else:
            self.is_accending[key] = True
        
        # 単語帳の表示
        self._show_words()
~~~

### 全体
GUIの全体のソースコードは以下になります。(上2つのクラスを繋げたもの)

~~~python:app.py
# -*- coding: utf-8 -*-
import tkinter as tk
import pandas as pd
import numpy as np
from tkinter import ttk
from search import Search

# 検索ウィンドウのサイズ <class 'str'>
MAIN_WINDOW_SIZE = "360x170"

# 英単語帳を表示するウィンドウのサイズ <class 'str'>
VIEWER_WINDOW_SIZE = "500x500"

# 英単語帳が格納されているフォルダパス<class 'str'>
PATH = 'wordbook/'

# 英単語帳の名前 <class 'str'>
WORDS_NAME = 'list_en.csv'

# 英単語帳の列名 <class 'dict'>
COLUMN_NAME = {0:'word', 1:'count', 2:'mean'}

class App:
    # GUIを表示するクラス
    
    def __init__(self):
        # 検索ウィンドウ
        self.root = tk.Tk()
        
        # 検索ウィンドウのウィジェット作成
        self._create_window()
        
        # 検索ウィンドウのメニューバー作成
        self._create_menu()
        
        # 検索ウィンドウのメインフレーム作成
        self._create_main_frame()
        
        # 検索機能を実装
        self.search = Search()
        
    def _create_window(self):
        # 検索ウィンドウの設定をする関数
        
        # ウィンドウタイトルを決定
        self.root.title("調べる英単語帳")

        # ウィンドウの大きさを決定
        self.root.geometry(MAIN_WINDOW_SIZE)

        # ウィンドウのグリッドを 1x1 にする
        self.root.grid_rowconfigure(0, weight=1)
        self.root.grid_columnconfigure(0, weight=1)
        
    def _create_menu(self):
        # メニューバーを作成する関数
        
        # メニューバーの作成
        menu = tk.Menu()
        self.root.config(menu=menu)
        
        # ファイルバーの作成
        menu_file = tk.Menu()
        menu.add_cascade(label='ファイル', menu=menu_file)
        menu_file.add_command(label='終了', command=lambda:self._close())
        
        # 表示バーの作成
        menu_display = tk.Menu()
        menu.add_cascade(label='表示', menu=menu_display)
        menu_display.add_command(label='英単語一覧', command=lambda:self._open_viewer())

    def _create_main_frame(self):
        # 検索ウィンドウのウィジェットを作成する関数
        
        # メインページフレーム作成
        self.main_frame = tk.Frame()
        self.main_frame.pack(fill=tk.X)
        
        # 検索テキストボックス
        self.search_box = ttk.Entry(self.main_frame, font=('Constantia', '20'))
        self.search_box.grid(row=0, column=0, sticky=tk.EW, padx=5, pady=10)
        self.search_box.bind('<Return>', self._search)
        
        # 検索画像ボタン
        self.img = tk.PhotoImage(file='materials/search.png').subsample(8,8)
        self.search_button = tk.Button(self.main_frame, image=self.img, relief='flat', command=lambda:self._search())
        self.search_button.grid(row=0, column=1, pady=5)
        
        # 結果テキストボックス
        self.result_box = tk.Text(self.main_frame, width=47, height=6, font=('Constantia', '10'))
        self.result_box.grid(row=1, column=0, columnspan=2, sticky=tk.W, padx=5)
        # 縦方向のスクロールバーの追加
        ybar = tk.Scrollbar(self.main_frame, orient=tk.VERTICAL)
        ybar.grid(row=1, column=1, sticky=tk.N+tk.S+tk.E)
        ybar.config(command=self.result_box.yview)
        self.result_box.config(yscrollcommand=ybar.set)

    def _search(self, event='<Return>'):
        # 検索を行う関数
        # 引数: event <class 'str'>
        
        # 検索した英単語の意味 <class 'str'>
        mean = ""
        
        # 検索した英単語のこれまでの表示回数 <class 'str'>
        count = ""
        
        # 結果テキストボックスに文字が含まれていたときクリアする
        if self.result_box.get('1.0', tk.END):
            self.result_box.delete('1.0', tk.END)
        
        # 検索ボックスに入力された文字を取得 <class 'str'>
        word = self.search_box.get()
        
        # 検索する英単語を渡す
        self.search.set_word(word)
        
        # 単語帳を渡す(デフォルトで'list_en.csv')
        self.search.set_csv(PATH + WORDS_NAME)
        
        # 検索する英単語が過去に検索したか確認
        if self.search.is_searched():
            # 意味と検索回数を取得し、検索回数を1追加する
            mean, count = self.search.get_mean_count()
            self.search.add_count()
        
        else:
            # 検索して意味を取得
            mean = self.search.search()
        
        # 結果ボックスに意味と検索回数を格納
        self.result_box.insert('1.0', mean)
        self.result_box.insert(tk.END, count)
        
        # 単語帳を更新
        self.search.update()
    
    def _open_viewer(self):
        # 単語帳の内容を表示する関数
        Viewer(self.root, PATH + WORDS_NAME)
        
    def _close(self):
        # アプリを終了する関数
            self.root.destroy()

class Viewer:
    # 単語帳の内容を表示するクラス
    
    def __init__(self, root, name):
        # ビューウィンドウ
        self.root = tk.Toplevel(root)
        
        # 単語帳の読み込み
        self.df = pd.read_csv(name)
        
        # 単語帳の内容を表示するテーブル
        self.tree = None
        
        # ソートの切り替え
        self.is_accending = [True, True, True]
        
        # ビューウィンドウを作成
        self._create_window()
        
        # テーブルの作成
        self._create_tree_view()
        
        # 単語帳の内容を表示
        self._show_words()

    def _create_window(self):
        # ビューウィンドウを作成する関数
        
        self.root.title =('英単語一覧')
        self.root.geometry(VIEWER_WINDOW_SIZE)
        
        # ウィンドウのグリッドを 1x1 にする
        self.root.grid_rowconfigure(0, weight=1)
        self.root.grid_columnconfigure(0, weight=1)
        
    def _create_tree_view(self):
        # テーブルを作成する関数
        
        # フレームの作成
        frame = tk.Frame(self.root)
        frame.pack(fill=tk.BOTH, expand=True, padx=5, pady=5)
        frame.columnconfigure(0, weight=1)
        frame.rowconfigure(0, weight=1)
        
        # テーブルの作成
        self.tree = ttk.Treeview(frame)
        self.tree.column('#0', width=50, stretch=tk.NO, anchor=tk.E)
        self.tree.grid(row=0, column=0, sticky=tk.W + tk.E + tk.N + tk.S)

        # 縦方向のスクロールバーを追加
        vscrollbar = ttk.Scrollbar(frame, orient=tk.VERTICAL, command=self.tree.yview)
        self.tree.configure(yscrollcommand=vscrollbar.set)
        vscrollbar.grid(row=0, column=1, sticky=tk.W + tk.E + tk.N + tk.S)
        
        # 列を３列作る
        self.tree["column"] = (1, 2, 3)
        self.tree["show"] = "headings"
        
        # ヘッダーテキスト
        self.tree.heading(1, text="英単語", command=lambda:self._sort(COLUMN_NAME[0]))
        self.tree.heading(2, text="検索回数", command=lambda:self._sort(COLUMN_NAME[1]))
        self.tree.heading(3, text="意味", command=lambda:self._sort(COLUMN_NAME[2]))
        
        # 列の幅
        self.tree.column(1, width=100)
        self.tree.column(2, width=50)
        self.tree.column(3, width=300)
    
    def _show_words(self):
        # 単語帳の内容を表示する関数
        
        # 内容をクリア
        self.tree.delete(*self.tree.get_children())
        
        # データ挿入
        for i in self.df.index:
            self.tree.insert("", "end", values=(self.df['word'][i], self.df['count'][i], self.df['mean'][i]))
        
    def _sort(self, col_name):
        # 単語帳にある英単語を並び替える関数
        # 引数: col_name <class 'str'>
        
        # 列名からキーを取得
        key = [k for k, v in COLUMN_NAME.items() if v == col_name][0]
        
        # 英単語を並び替え
        self.df = self.df.sort_values(col_name, ascending=self.is_accending[key])
        
        # ソート方法の切り替え
        if self.is_accending[key]:
            self.is_accending[key] = False
        else:
            self.is_accending[key] = True
        
        # 単語帳の表示
        self._show_words()
~~~

# 工夫点
- **UI**
説明書がなくても直感的にわかるデザインを意識しました。
例えば、入力欄の横に検索マークを置くことによって、左の枠に入力すれば良いということが分かるようにしました。
![検索ウィンドウ.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1495498/059effe3-2cb8-cbf2-0586-efc28b857854.gif)

- **ソート機能**
1つのボタンで英単語や検索回数を昇順と降順に並び替えられるようにしました。
この機能も直感的に分かるように、ヘッダをクリックすると並び替えられる仕様にしました。
![ビューウィンドウ.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1495498/34ced043-7e01-d20c-c7f3-a842d7f4a5a4.gif)

# まとめと感想と今後
今回はPythonで検索機能付きの英単語帳を作成してみました。
アプリのGUI化は今回が初めてだったので色々勉強になりました。
作成する前はロジックの方に手間取るかなと予想していたのですが、ユーザが使いやすくてシンプルなUIをデザインするのに1番時間がかかりました。(Notepad++やYoutubeなどの検索エンジンのデザインを参考にしてました。)
今後は日本語→英語の翻訳を可能にしたり、問題を出力する機能を付けたりなど拡張していこうと思います。
コードに無駄があったり、こうした方が良いという点があればコメントで教えて頂けると幸いです。
ここまで読んで頂きありがとうございました。

# 参考URL
- [pythonでwebスクレイピング](https://qiita.com/pontyo4/items/264b03d1f718fb55eb08)
- [Tkinterの使い方](https://daeudaeu.com/category/python/tkinter/tkinter_tutorial/)
- [CSVファイルのビューワーをTkinterで作成する](https://data-anal-ojisan.com/2020/11/30/556/)