Python+Flask+Pandasで作ったおれおれ電話帳

f:id:wwater:20170713113947p:plain

趣味でローカルなWebサービスを作ってみました。

仕事場などに掛かってくる電話は基本的に決まっているのですが、滅多にかけてこない相手もいます。
そして電話機能が持っている電話帳って数が少ないため、全部登録できません。

かといってネットの電話番号検索は、電話が掛かっている最中の検索としてちょっと遅い。
よってある程度の地域やローカルな番号はあらかじめcsvファイルとして保存しておく方法を取りました。

Python3+Pandas+Flask+JQueryAjaxという構成。 データはそれほど多くないのでDataFrameに読み込ませました。

検索する所に番号を入力していくと、段々と候補が絞られていく感じ。 リストになければ、リストをゼロにする感じです。

実は追加で、リストになければ他の迷惑電話検索サービスに検索にいくようにしようかと思っています。
が、それはそのサイトの利用規約によるのでどうしようかなー、という感じです。

また検索した電話がリストになければ登録フォームを表示するようにしようかと思っています。

なぜこれがローカルなのかというと、ユーザー登録管理は考えてないから。

一応ルーターで外からアクセスは出来なくしているし、自分のパソコンのみのアクセスにしている。

ここまで来たらユーザー管理してログイン関係も出来るんだけど、満足している所があるしなぁ。

おかげでFlaskはUIとして使っているだけです。 python部分は全部乗せます。訳わからなくなってくるかもしれない。

ソートとかcsvの読み書きについてはpandasに任せます。

実はpandasにする理由がないと思う。

検索はstr.containsまかせだし。

フォルダ構成はこんな感じ。

-app.py
-tel_data.csv

+-static
  +-css
    -bootstrap.min.css
    -bootstrap.min.css.map
  +-js
    -bootstrap.min.js
    -jquery-2.1.4.min.js
    -usrlocal.js
+-templates
  -add.html
  -index.html
  -layout.html

tel_data.csvの中身

tel,name,desc,flag
011,北海道,国際電話もあり,1
0120987654,リサイクル買い取り,,2
0120123456,宣伝FAX請負業者,悪質な宣伝FAX 2099/09/09 2回目,2
0333334444,,,2
0120999,契約している電気業者系?,,0

ソースに書いてありますが、csvのフラグは0で標準、1で怪しい、2で迷惑電話という一覧にしてます。

このデータはWebでは編集できません、追加だけです。

編集したきゃCSVでやってくれという投げっぱなし。

以下、ソースを一部張っていきます。

from flask import Flask, session, render_template, request, redirect, url_for, jsonify
import pandas as pd
import os

class TellData:
    """
    電話データの読み書きを行う
    データは同じ場所にある'tel_data.csv'
    """
    __tel_data = "./tel_data.csv"
    __ci = ["tel", "name", "desc", "flag"]

    def __init__(self, fname=__tel_data, keta=3):
        """
        初期に'./tel_data.csv'のデータをDataFrameに読み込んでいます。
        読み込まれたデータは df にDataFrameで格納されています。
        """
        super(TellData, self).__init__()
        self.__fname = fname
        self.reload()
        self.__keta = keta

    def reload(self):
        tmp_df = pd.read_csv(self.__fname, header=0, dtype=str)
        assert isinstance(tmp_df, pd.DataFrame)
        tmp_df["tel"] = tmp_df["tel"].str.replace("-", "")
        self.df = tmp_df.fillna("")  # Jquery.parseで"Non"だとエラーとなる

    def save(self):
        """
        DataFrameに定義されたファイルを、読み込んだファイルに上書き保存する。
        sortして保存する。
        """
        self.df.sort_values(by=self.__ci[0]).to_csv(self.__fname, index=False, encoding="utf-8")

    def etcsort(self, ans):
        # 短い順に並べ替え
        ans.flag = ans.flag.astype(int)
        ans = ans.sort_values(by="flag", ascending=True)[self.__ci]
        ret = []
        for key, col in ans.iterrows():
            ret.append(dict(col))
        return ret


    def topfilter(self, telnum):
        """
        引数では途中まででも入力されたデータを文字列で読み込みます。
        先頭から一致した一覧を戻す
        ハイフンがあっても、削除しますので入力はどちらでも可能。
        戻り値は配列で、中はdictになっている。
        dictの中身は["tel","name","desc", "flag"]
        なければ空の配列が戻る
        """

        # 数値タイプだった場合、文字列変換する
        telnum = self.__check_type(telnum)
        if len(telnum) < self.__keta:
            return []

        ans = self.df[self.df["tel"].str.contains(telnum)]
        ret = self.etcsort(ans)

        return ret

    def namefilter(self, nstr):
        """
        名前のフィルタ
        """
        ans = self.df[self.df["name"].str.contains(nstr)]
        ret = self.etcsort(ans)
        return ret


    def add(self, tel, name, desc, flag=0):
        """
        入力した電話番号、名前、コメントをDataFrameに入れる
        その結果はself.saveでcsvファイルに保存される。
        telのみNoneならエラー
        """
        if not tel:
            print("tel number is None, please input number.")
            return

        # 数値タイプだった場合、文字列変換する
        tel = self.__check_type(tel)

        # 同じデータがあるか確認
        ans = self.df[self.df.tel == tel]
        if len(ans) == 1:
            print("重複データがあるので、登録しません。編集してください")
            return
        y = pd.Series([tel, name, desc, flag], index=self.__ci)
        self.df = self.df.append(y, ignore_index=True)

    def __check_type(self, indata):
        if isinstance(indata, int):
            indata = str(indata)
        elif isinstance(indata, str):
            indata = indata.replace("-", "")
        return indata


app = Flask(__name__)
td = None


@app.route("/")
def index():
    return render_template('index.html')


@app.route("/reload", methods=['POST'])
def reload():
    td.reload()
    return redirect(url_for('index'))


@app.route('/post', methods=['POST'])
def post():
    gdata = request.json

    # 以下ここで電話番号計算の処理をする
    gtel = td.topfilter(gdata)
    result = {
        "teldata": gtel
    }
    return jsonify(result)

@app.route('/npost', methods=['POST'])
def npost():
    gdata = request.json
    gname = td.namefilter(gdata)
    result = {
        "namedata": gname
    }
    return jsonify(result)

@app.route('/add_data')
def add_data():
    return render_template('add.html')

@app.route('/add', methods=['POST'])
def add():
    if request.method  == 'POST':
        if 0 <= len(request.form["tel"]) <= 8:
            session['verror'] = "電話番号だけは入力してください"
            return render_template('add.html')
        else:
            session.pop('veeror', None)
            td.add(request.form['tel'], request.form['name'],
                   request.form['desc'], request.form['number'])
            td.save()
            return render_template('index.html')

if __name__ == "__main__":
    td = TellData(keta=0)
    app.secret_key = os.urandom(24)
    app.run(host='0.0.0.0', debug=True)

と、flask関係はこれだけ。

ワンファイルに書いてあり、デバッグモードで動かしています。

あとはpythonで電話関係のクラスを一つ作って中で処理して、表示はJQueryにお任せです。

気が向いたら改造しよう。

ちなみにJavascriptの中身がなかなか濃い。

なんせテーブル部分は全てJavascriptで書いているから。

enterやボタンを押して検索するのは、ローカルでは手間だから入力された時のkeyupのタイミングでテーブル描画してみた。

速度が大事なのでhtmlの追加部分はJQueryのappendではなく、innerHTMLを使ってみた。

デザインは苦手な為、Bootstrapを使う。

$(function() {
        $('#tel').each(function() {
            $(this).bind('keyup', cht(this));
            function cht(elm) {
                var v, old = elm.value;
                return function() {
                    if (old != (v=elm.value)) {
                        old = v;
                        str = $(this).val();
                        $.ajax({
                                type: "POST",
                                url: '/post',
                                data: JSON.stringify(str),
                                dataType: 'json',
                                processData : true,
                                contentType : 'application/json',
                                success: function(result) {
                                    $('table tbody#ial').empty();
                                    // $('#ial').children().remove();
                                    addlist(result.teldata);

                                },
                                error: function(xhr, textStatus, errorThrown) {
                                    console.log(xhr.responseText);
                                    var res = $.parseJSON(xhr.responseText);
                                    console.dir(res);
                                }
                        });
                    }
                }
            }
        });
        $('#nid').each(function() {
            $(this).bind('keyup', cht(this));
            function cht(elm) {
                var v, old = elm.value;
                return function() {
                    if (old != (v=elm.value)) {
                        old = v;
                        str = $(this).val();
                        $.ajax({
                                type: "POST",
                                url: '/npost',
                                data: JSON.stringify(str),
                                dataType: 'json',
                                processData : true,
                                contentType : 'application/json',
                                success: function(result) {
                                    $('table tbody#ial').empty();
                                    // $('#ial').children().remove();
                                    addlist(result.namedata);

                                },
                                error: function(xhr, textStatus, errorThrown) {
                                    console.log(xhr.responseText);
                                    var res = $.parseJSON(xhr.responseText);
                                    console.dir(res);
                                }
                        });
                    }
                }
            }
        });
        function addlist(dd) {
            var insert = "";
            for(var i = 0; i < dd.length; i++) {
                // tdタグを生成してテキスト追加
                var flang = "";
                if (dd[i]["flag"] == "2") {
                    insert += "<tr class='danger'>";
                    flang = "迷惑電話";
                } else if (dd[i]["flag"] == "1") {
                    insert += "<tr class='warning'>"
                    flang = "怪しい";
                } else {
                    insert += "<tr class='info'>";
                    flang = "OK";
                }
                insert += "<td>" + dd[i]["tel"]  + "</td>";
                insert += "<td>" + dd[i]["name"] + "</td>";
                insert += "<td>" + dd[i]["desc"] + "</td>";
                insert += "<td>" + flang + "</td>";
                insert += "</tr>"
            }
            // insertを#ial内に追加
            $('table tbody#ial')[0].innerHTML = insert;
        }

        $('#reload').click(function(){
            $.ajax({
                    type: "POST",
                    url: '/reload',
                    dataType: false,
                    processData : false,
                    contentType : 'application/json',
                    success: function(result) {
                    }
            })
        })

}
);

どなたか忘れましたが、ネットにあるサンプルをいじった程度。

Javascripは詳しくないので分かるところだけいじりました。

人のサンプルを見ていると、本体のhtmlにデータを書いて、省略しているとか表示を絞っている感じの物が多い。

私は検索するたびに、thタグ以下をempty()して描画したhtmlを追加という形にした。ローカルじゃないと出来ない贅沢だね!

どちらが正しいのかは不明。たぶんデータ件数によってかわるんだろうなぁ。

index.htmlがどうなっているのか書いてみる。

{% extends "layout.html" %}
{% block content %}
  <!-- Form
  ================================================== -->
<script src="static/js/usrlocal.js"></script>
<div class="container-fluid">
  <div class="row">
    <div class="col-md-1">

    </div>
    <div class="col-md-10">

      <div class="page-header">
        <h1>電話番号検索    <small id='reload' type="button" class="btn btn-primary">csv読み直し</small></h1>
        <a href="./add_data">電話番号追加</a>
      </div>
      <p></p>

      <div class="form-group input-group input-group-lg">
        <span class="input-group-addon">検索する電話番号</span>
        <input type="text" id="tel" style="ime-mode: disabled" class="form-control" placeholder="電話番号を入力してください">
        <span class="input-group-addon">検索する名前</span>
        <input type="text" id="nid" style="ime-mode: enable" class="form-control" placeholder="名前を入力してください">
      </div>

      <div class="table-responsive">
        <table class="table table-bordered table-hover table-condensed">
          <thead class="thead-inverse">
            <tr>
              <th class="col-md-2">電話番号</th>
              <th class="col-md-2">名前</th>
              <th class="col-md-3">説明</th>
              <th class="col-md-3">状態</th>
            </tr>
          </thead>
          <tbody id='ial'>
          </tbody>
        </table>
      </div>
    </div>
    <div class="col-md-1">

    </div>
  </div>
</div>
{% endblock %}