電話番号トラッカーのアプリを作る - Python3 / PyQt5

追記: windowのresizeに対応したほうが良いでしょうか？

固定電話番号の大まかな住所を教えてくれるアプリを作ります。
固定電話番号は10桁ではじめの一桁は「国内プレフィックス」二桁目からの五桁が「市外市内局番」となります。今回使うのは二桁目から五桁目となります。
[イメージ](https://www.soumu.go.jp/main_content/000270960.gif) ([電気通信番号指定状況](https://www.soumu.go.jp/main_sosiki/joho_tsusin/top/tel_number/number_shitei.html) (総務省))

## 見た目

### イントロダクション

まず見た目の部分のを作って行きます。

ページは２ページで`main`ページで電話番号を入力し、`result`ページで検索結果を表示します。それぞれわかりやすいようにまず階層構造を簡単にHTMLで表しましました。

メインページ(`main_page`)

```
<div class="main_page">
    <p class="main_page_title"></p>
    <p class="main_page_note"></p>
    <input class="main_page_phone_number">
    <input class="main_page_track_btn">
</div>
```

リザルトページ(`result_page`)

```
<div class="result_page">
    <p class="result_page_title"></p>
    <p class="result_page_detail"></p>
</div>
```

こんな感じです。そしてこれらを、`StackedWidget`と呼ばれるものに追加します。それぞれ`setCurrentIndex(int)`でページを切り替えます。

結果的に次のような形になります。最終的にメインウィンドウに`centralwidget`をセットします。

```
<div class="centralwidget">
	<div class="stackedwidget">
		<div class="main_page">
		...
		</div>
		<div class="result_page">
		...
		</div>
	</div>
</div>
```

### コーディング

#### 自作メソッド

```
def setup_qlabel(self, label, text, size, color='#2e2e2e'):
    label.setText(text)
    label.setFont(self.setup_font(size))
    label.setAlignment(QtCore.Qt.AlignHCenter)
    label.setStyleSheet('color: {}'.format(color))
        
def setup_font(self, size):
    font = QtGui.QFont()
    font.setPointSize(size)
    return font
```

`setup_qlabel`は`QLabel`を設定する共通のメソッド。デフォルトのテキストカラーは`#2e2e2e`としている。

`setup_font`は`QLabel`等のフォントサイズを設定する。

#### main_window

```
main_window.setWindowTitle('Phone-number-tracker')
size = {
    'width': {
        'default': 600,
        'minimum': 100,
        'maximum': 10000
    },
    'height': {
        'default': 400,
        'minimum': 100,
        'maximum': 10000
    }
}
main_window.resize(size['width']['default'],
                   size['height']['default'])
main_window.setMinimumSize(size['width']['minimum'],
                           size['height']['minimum'])
main_window.setMaximumSize(size['width']['maximum'],
                           size['height']['maximum'])
```

メインウィンドウのサイズを設定する。

#### centralwidget

`self.centralwidget = QtWidgets.QWidget(main_window)`

設定:

```
self.centralwidget.setStyleSheet('background-color: #eaeaea;')
```

#### stackedwidget

`self.stackedwidget = QtWidgets.QStackedWidget(self.centralwidget)`

設定:

```
self.stackedwidget.setGeometry(100, 100, 
                               size['width']['default'] - 200,
                               size['height']['default'] - 200)
```

#### main_page

`self.main_page = QtWidgets.QWidget()`

設定:

```
self.main_page.setGeometry(100, 100, 
                           size['width']['default'] - 200,
                           size['height']['default'] - 200)
self.main_page_layout = QtWidgets.QVBoxLayout(self.main_page)
```

#### main_page > title

`self.main_page_title = QtWidgets.QLabel(self.main_page)`

設定:

```
self.setup_qlabel(self.main_page_title, 'The phone number tracker', 24, '#ea5506')
self.main_page_title.setFixedHeight(100)
```

`setFixedHeight`で高さを設定している。`setup_qlabel`では`(何に, このテキストを, このサイズで, このカラーで)`という順になっている。

#### main_page > note

`self.main_page_note = QtWidgets.QLabel(self.main_page)`

設定:

```
self.setup_qlabel(self.main_page_note, "Note: No '-' needed", 12, '#3e3e3e')
```

電話番号にハイフンは必要で無いことを示している。

#### main_page > phone_number

`elf.main_page_phone_number = QtWidgets.QLineEdit(self.main_page)`

設定:

```
self.main_page_phone_number.setFont(self.setup_font(14))
self.main_page_phone_number.setMaxLength(34)
self.main_page_phone_number.setStyleSheet('color: #2e2e2e; border: 2px solid #7fbfff; border-radius: 4px; padding: 2px')
```

#### main_page > track_btn

`self.main_page_track_btn = QtWidgets.QPushButton(self.main_page)`

```
self.main_page_track_btn.setText('Track')
self.main_page_track_btn.setFont(self.setup_font(14))
self.main_page_track_btn.setStyleSheet('color: #2e2e2e;')
```

#### result_page

`self.result_page = QtWidgets.QWidget()`

設定:

```
self.result_page.setGeometry(100, 100,
                             size['width']['default'] - 200,
                             size['height']['default'] - 200)
self.result_page_layout = QtWidgets.QVBoxLayout(self.result_page)
self.result_page_layout.setContentsMargins(0, 0, 0, 0)
```

このページでは少しオブジェクトがキツキツなので`setContentsMargines`でマージンを0にしている。

#### result_page > title

`self.result_page_title = QtWidgets.QLabel(self.result_page)`

設定:

```
self.setup_qlabel(self.result_page_title, '', 16)
self.result_page_title.setFixedHeight(26)
```

#### result_page > detail

`self.result_page_detail = QtWidgets.QPlainTextEdit(self.result_page)`

設定:

```
self.result_page_detail.setFont(self.setup_font(12))
self.result_page_detail.setStyleSheet('border: 2px solid #7fbfff; border-radius: 4px')
self.result_page_detail.verticalScrollBar().setStyleSheet('border: none')
self.result_page_detail.setReadOnly(True)
```

三行目では垂直方向のスクロールバーのスタイルを指定している。

#### それぞれをレイアウトに組み込む

メインページ

```
self.main_page_layout.addWidget(self.main_page_title)
self.main_page_layout.addWidget(self.main_page_note)
self.main_page_layout.addWidget(self.main_page_phone_number)
self.main_page_layout.addWidget(self.main_page_track_btn)

self.stackedwidget.addWidget(self.main_page)
```

1~4行目で`main_page`にそれぞれのオブジェクトを順に追加している。(詳しくは、`main_page`のレイアウトである`main_page_layout`に追加している)最後に、`stackedwidget`にページを追加している。

リザルトページ

```
self.result_page_layout.addWidget(self.result_page_title)
self.result_page_layout.addWidget(self.result_page_detail)

self.stackedwidget.addWidget(self.result_page)
```

#### 仕上げ

初期のページを設定する。

```
self.stackedwidget.setCurrentIndex(0)
```

メインウィンドウに`centralwidget`をセットする。

```
main_window.setCentralWidget(self.centralwidget)
```

![index0.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/448552/7d233c55-c3f7-2ba1-b0a4-9ac791290ebe.png)

![index1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/448552/8491b5b1-a93a-d99a-6aaf-a3d30e2ac0a2.png)

## 中身

###PhoneNumber class

まず`PhoneNumber`クラスを作る。そして必要なのは2桁目から6桁目なのでその5桁を取り出す。

```
class PhoneNumber:
    def __init__(self, number):
        self.number = number
        self.trimmed_number = number[1:6]
```

次にホントに固定電話の番号かを軽くチェックするメソッドを作る。

```
    def check_format(self):
        length = len(self.number) == 10
        is_digit = self.number.isdigit
        return length and is_digit
```

最後に住所を得るメソッドを作る。

```
    def get_address(self):
        outer_list = [self.trimmed_number[:i] for i in range(1, 5)]
        data = pd.read_pickle('phone_numbers_data.pickle')
        result = data[data.outer.isin(outer_list)]
        result = result[result.inner.str.len() == result.inner.str.len().min()]
        address = result['address']
        return list(address)
```

5桁のうち、何桁までが市外局番かわからないので1桁の場合から4桁の場合までそれぞれ取り出す。次の例では`outer_list`は

電話番号:`0123456789` => `outer_list`: `['1', '12', '123', 1234']`

のようになる。二行目でデータを読み込み、三行目で`outer`列要素が`outer_list`に属している行をすべて取り出している。四行目で`inner`列要素の文字列の長さが一番短いつまり`outer`列要素が貪欲マッチする行を絞り込む。最後に`inner`,`outer`はもういらないので住所だけを取り出し、リストに変換しリターンする。

### SearchJob class

```
class SearchJob():
    def __init__(self, value):
        self.phone_number = PhoneNumber(value)
        
    def search(self):
        if self.phone_number.check_format() is False:
            raise ValueError('Error: Invalid format')

        self.result = self.phone_number.get_address()
        if self.result is None:
            raise ValueError('Error: Not address found')

    def finish(self):
        return self.string_converter(self.result)

    def string_converter(self, result):
        return ''.join(f'{i+1}: {item}\n' for i, item in enumerate(result))
```

クリックイベントの関数内でこのインスタンスを生成する。次に`search`メソッドを呼び出し、先程作った`check_format`メソッドが`False`を返すなら、例外を上げて、住所が取得できない場合も例外を上げる。`string_converter`メソッドでは、リストになった住所を次のような文字列に変換する。

```
1: 住所
2: 住所
...
```

#### クリック時に実行する関数

```
    def clicked_track(self):
        number = self.main_page_phone_number.text()
        job = SearchJob(number)
        try:
            job.search()
            result = job.finish()
            self.show_result(number, result)
        except ValueError as err:
            result = str(err)
            self.show_result(number, result)

    def show_result(self, number, result):
        self.result_page_title.setText(f'Result: {number}')
        self.result_page_detail.setPlainText(result)
        self.stackedwidget.setCurrentIndex(1)
```

try節内で例外が発生した場合は例外を`show_result`に渡して`result_page > defail`に表示する。それ以外の場合は住所を表示する。

最後に`main_page > track_btn`の設定に`self.main_page_track_btn.clicked.connect(self.clicked_track)`を追加してクリックされたらこれを実行するようにする。

## main.pyを作って動かす。

```
import sys
from PyQt5 import QtWidgets
from ui_class import UiMainWindow

app = QtWidgets.QApplication(sys.argv)
main_window = QtWidgets.QMainWindow()
ui = UiMainWindow()
ui.setup_ui(main_window)

main_window.show()
sys.exit(app.exec_())
```

お疲れ様でこれで完成です。下の電話番号のデータも忘れないでください！

**イメージ**
![Peek 2020-12-04 22-02.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/448552/113e7283-cb6c-7698-4e00-e63eb51db1d9.gif)
**登場する電話番号は実在の人物や団体などとは関係ありません。**

## 付録

[ui_class.py](https://github.com/suzukaze31/phone_number_tracker/blob/main/ui_class.py)
[search_job.py](https://github.com/suzukaze31/phone_number_tracker/blob/main/search_job.py)
[phone_numbers.csv](https://github.com/suzukaze31/phone_number_tracker/blob/main/phone_numbers.csv) ([市外局番の一覧](https://www.soumu.go.jp/main_sosiki/joho_tsusin/top/tel_number/shigai_list.html)(総務省)(https://www.soumu.go.jp/main_content/000157336.doc) をCSVに変換して作成)

mk_pickle.py

```
import pandas as pd


pd.read_csv('phone_numbers.csv', names=['address', 'outer', 'inner']).to_pickle('phone_numbers_data.pickle')
```