もなかアイスの試食品

「とりあえずやってみたい」そんな気持ちが先走りすぎて挫折が多い私のメモ書きみたいなものです.

【Python】運行情報をSlack API を使って通知するデーモンを作ってみた

前にPythonでYahoo路線情報から運行情報をスクレイピングで取得するアプリを作った

monakaice88.hatenablog.com

あとPythonスクリプトをデーモン化させる方法もわかった

monakaice88.hatenablog.com

なので今回は運行情報が変わったときに、Slack APIで通知するデーモンを作ることにした

ざっくり機能まとめ

  • 10分間隔でYahoo路線情報にアクセスして、スクレイピング
  • 情報を監視する路線は複数対応可能
  • 以下の状態でSlackに通知する
    • トラブルを検知したとき
    • トラブルが無くなったとき
    • 情報が更新されたとき(掲載日時が変わったとき)

Pythonスクリプトの作成

普段Pythonを触らないので、命名規則やら標準関数・クラスに苦しみながら作ってみた

まずは、Yahoo路線情報からスクレイピングする機能を作成

#!/ust/bin/env python
# -*- coding: utf-8 -*-

from abc import ABCMeta, abstractmethod
from bs4 import BeautifulSoup
from datetime import datetime
from urllib import request
from my_util import logger, dict_to_str


class Scraper(metaclass=ABCMeta):
    def __init__(self, url):
        self._url = url

    @abstractmethod
    def get_data(self):
        pass

    def __make_proxy_url(self):
        pass


class JRService(Scraper):
    def __init__(self, url):
        super().__init__(url)

    def get_data(self):
        html = request.urlopen(self._url).read()
        soup = BeautifulSoup(html, 'lxml')

        # 路線情報
        route_element = soup.find('div', {'id': 'main'}).find('div', {'class': 'mainWrp'}).find('div', {'class': 'labelLarge'})
        route_name = route_element.find('h1', {'class': 'title'}).find(text=True, recursive=False)
        update_time_str = route_element.find('span', {'class': 'subText'}).find(text=True, recursive=False)

        update_time = self.__get_update_datetime(update_time_str)

        # 運行情報
        status_element = soup.find('div', {'id': 'mdServiceStatus'})
        information = status_element.find('p').find(text=True, recursive=False)

        posting_element = status_element.find('p').find('span')
        posting_data = self.__get_posting_datetime(posting_element)

        message = information + posting_data['datetime_str']

        trouble_class = status_element.find('dd', {'class': 'trouble'})
        in_trouble = False
        if trouble_class is not None:
            in_trouble = True

        data = {
            'route_name': route_name,
            'update_datetime': update_time,
            'posting_datetime': posting_data['datetime'],
            'message': message,
            'in_trouble': in_trouble
        }

        logger.info(dict_to_str(data))

        return data

    @staticmethod
    def __get_update_datetime(update_time_str):
        year = datetime.today().year
        convert_str = '%d年%s' % (year, update_time_str)
        update_time = datetime.strptime(convert_str, '%Y年%m月%d日 %H時%M分更新')
        return update_time

    @staticmethod
    def __get_posting_datetime(element):
        posting_date_str = ''
        posting_date = None
        if element is not None:
            posting_date_str = element.find(text=True, recursive=False)
            year = datetime.today().year
            convert_str = '%d年%s' % (year, posting_date_str)
            posting_date = datetime.strptime(convert_str, '%Y年(%m月%d日 %H時%M分掲載)')

        return {
            'datetime_str': posting_date_str,
            'datetime': posting_date
        }


次に周期的にスクレイピング処理を実行し、情報が変わっていたら、通知する処理を作成

#!/ust/bin/env python
# -*- coding: utf-8 -*-


from configparser import ConfigParser
from datetime import datetime
from scraping import JRService
from threading import Timer
from time import sleep
from sys import exit
from my_util import logger
import requests


config = ConfigParser()
config.read('./.env')
thread_time_min = config.getint('settings', 'watch_interval_min')
thread_time_sec = thread_time_min * 60
services = config.items('jr_services')


def push_slack_chat(name, message):
    url = config.get('settings', 'post_url')
    token = config.get('settings', 'token')
    channel = config.get('settings', 'channel')
    data = {
        'token': token,
        'channel': channel,
        'username': name,
        'text': message
    }
    requests.post(url, data=data)


def scrape_service():
    try:
        for key, url in services:
            scraper = JRService(url)
            data = scraper.get_data()

            last_posting_datetime_str = config.get('posting_datetime', key)
            last_posting_datetime = datetime.strptime(last_posting_datetime_str, '%Y-%m-%d %H:%M')

            last_in_trouble = config.getboolean('in_trouble', key)
            if data['in_trouble'] and (data['posting_datetime'] <= last_posting_datetime):
                continue
            elif (data['in_trouble'] and not last_in_trouble)\
                    or (last_in_trouble and not data['in_trouble'])\
                    or data['in_trouble'] and (data['posting_datetime'] > last_posting_datetime):

                push_slack_chat(data['route_name'], data['message'])

                in_trouble_str = 'True' if data['in_trouble'] else 'False'
                config.set('in_trouble', key, in_trouble_str)
                if data['in_trouble']:
                    posting_datetime_str = data['posting_datetime'].strftime('%Y-%m-%d %H:%M')
                    config.set('posting_datetime', key, posting_datetime_str)

                config.write(open('./.env', 'w'))

    except Exception as e:
        logger.error(e.with_traceback())
    finally:
        thread = Timer(thread_time_sec, scrape_service)
        thread.start()


if __name__ == '__main__':

    scrape_service()

    try:
        while True:
            sleep(0.1)
    except KeyboardInterrupt:
        exit(0)

全体的に日時の文字列をdatetime型に変換する方法がなんか冗長な気がする・・・

↓参考にならないかもしれないソースコードはコチラ

github.com

supervisorの設定

作成したPythonスクリプトをデーモン化するため、supervisorの設定を追記

ここで使用しているsupervisorのバージョンは2.1

ちなみに、Pythonスクリプトの配置場所は「/usr/local/bin/Train-Service-Information」

[program:train_service_information]
command=bash -c '. /etc/profile; cd /usr/local/bin/Train-Service-Information; python -u main.py'
autostart=true
autorestart=true
startsecs=10
startretries=3
log_stdout=true
log_stderr=true
logfile=/var/log/supervisor/train_service_information.log

commandで「. /etc/profile」や「cd /usr/local/bin/Train-Service-Information」をしているのは、環境変数がなかったり、カレントがsupervisor2.1で設定出来ないため

あと「python -u main.py」の【-u】オプションは、スクリプトデバッグログが全然出力されないのを防ぐため、バッファリングを無効化するオプション

↓参考サイト

pythonで標準出力のバッファリングを無効にする - blog.mouten.info

実行結果

f:id:monakaice88:20170207065231p:plain

ちゃんと遅延が発生した時と遅延が収まった時にSlackに通知することが出来た。

次の機能について

Yahoo路線情報は情報の更新に大きめなタイムラグがある

ツイッターのつぶやきを解析して通知する機能を入れたほうがリアルタイムな感じになるかも

あと気象庁XMLを受信し始めてからかなり時間が経っているので、いい加減利用しなければ・・・

AngularJSで半角数値しか入力出来ないディレクティブを作ってみた

WEBアプリのバリデーション処理はめんどくさい

とある業務系のアプリを作成していて、 数値を入力してもらう箇所に普通の文字列を入れられ挙動がおかしくなることがあった

それぐらいバリデーション入れろよ・・・とツッコミが聞こえそうだけど、仕様が変わりすぎて追いつけなかったんや・・・

バリデーションのパターンを作るのも面倒なので、「そもそも数値以外入力できない」ようにしたい

なので数値しか入力できないディレクティブを作ってみた


ちょっとこだわった点

  • そもそも半角数値以外はインプットさせない
  • 全角の数値を自動で変換する
  • メッセージ等の見た目を良くしたいので、AngularStrapを利用


できたもの 実はこれ作ったの多分1年前

↓HTML

<div class="container" ng-app="App">
  <form>
    <p ng-init="hoge=234">
      <label for="age">強制半角フォーム : </label>
      <input type="text" id="age" name="age" ng-model="hoge"  ng-number-only/>
    </p>
    <p>
      model:{{hoge}}
    </p>
  </form>
</div>

Javascript(jQuery、AngularJS、AngularStrap)

(function(window) {
  'use strict';

  window.isNullOrEmpty = function(target) {
    if (typeof target === 'undefined' || target === null || target === '') {
      return true;
    }
    else {
      return false;
    }
  };
})(window);

(function(window, angular) {
  'use strict';

  var module = angular.module('App', ['mgcrea.ngStrap']);

  module.directive('ngNumberOnly', [
    '$tooltip',
    function($tooltip) {
      return {
        'restrict': 'A',
        'require': '?ngModel',
        'link': function(scope, el, atts, ctrl) {
          if (el[0].localName.toLowerCase() !== 'input' || atts.type !== 'text') {
            throw new Error('Support Only "input[type=text]"');
          }
          var title = '数字を入力してください';
          if (!window.isNullOrEmpty(atts.title)) {
            title = atts.title;
          }
          var tooltipParams = {
            'placement': 'right',
            'trigger': 'manual',
            'title': title
          };
          var myTooltip = $tooltip(el, tooltipParams);

          el.on('input propertychange', function(e) {
            var caretPosition = this.selectionStart;

            //半角0-9以外は
            if (/[^0-9]+/g.test(this.value)) {
              // 全角0-9は半角0-9へ
              this.value = this.value.replace(/[0-9]/g, function(s) { 
                  myTooltip.hide();
                caretPosition = caretPosition + 1;
                return String.fromCharCode(s.charCodeAt(0) - 0xFEE0);
              });
                // 0-9以外はアラートのツールチップを出す
              if (/[^0-9]+/g.test(this.value)) {
                  myTooltip.show();
              }
              // 半角英字記号半角スペースはキャレット(I)が右に動いてしまうので一つ左へ移動
              if(/[A-Za-z!"#$%&'()\*\+\-\.,\/:;<=>?@\[\\\]^_`{|}~\s]/.test(this.value)){ 
                caretPosition = caretPosition-1;
              }
              //0-9以外を全て空("")に置き換え
              this.value = this.value.replace(/[^0-9]+/g, '');
              //キャレットを元の位置に戻す
              this.selectionStart = caretPosition;
              this.selectionEnd = caretPosition;
            }
            else {
              myTooltip.hide();
            }

            if (ctrl != null) {
              // jQueryやイベントハンドラ(AngularJSの管轄外)から要素のvalueを変更すると、
              // modelに値が反映されない。htmlのvalueをmodelに反映するため、$setViewValueを呼び出す。
              // (コメントアウトすると分かりやすい)
              ctrl.$setViewValue(this.value);
             }
          });
          el.on('blur', function(e) {
            myTooltip.hide();
          });
        }

      };
    }
  ]);


})(window, window.angular);

CSS

.container{
  margin:50px;
  width:300px;
  height:300px;
  border:1px solid #ddd;
  padding:30px;
}

↓全角を半角にするのに参考にしたサイト

www.nishishi.com

動作確認


作っていてハマった点

  • キャレットの位置が変わる
  • Angularのモデルの値が変わらない

Angularのモデルの値が変わらないについて

AngularJSの管理外から値の変更があると、AngularJSが検知できない

scopeで検知できない感じ

AngularJSの管理外から値の変更した場合は、「$setViewValue」というメソッドを呼び出す必要がある。

↓参考サイト

ngModel.NgModelController | AngularJS 1.2 日本語リファレンス | js STUDIO

わかったことまとめ

  • AngularJSの管理外(scope外)から値の変更した場合は、「$setViewValue」を使用する
  • 全角数値を半角数値に変換する方法

Angular2がリリースされているけど、使ったことは無い

Angular2にこういうハマりそうなポイントがなければいいなー

Pythonスクリプトをデーモン化するのにsupervisorを利用した話

簡単なスクリプトをデーモン化出来ないかと調べたところ

supervisorを使うことでスクリプトをデーモン化させることが出来そう

↓参考にしたサイト

Supervisorで簡単にデーモン化 - Qiita

SupervisorでPythonのスクリプトをデーモンプロセスとして動かす - Symfoware


私が大好きなCentOS6.8でやってみた

ちなみに私の環境はこんな感じ


見出し


supervisor(version 2.1)のインストール

supervisorのインストールにepelリポジトリが必要なのでyumでインストール

# yum install epel-release

supervisorのインストール

# yum install supervisor

デーモン化するスクリプトの作成

とりあえず簡単なスクリプト

supervisorの動作確認のため、簡単なスクリプトを作成

「/usr/local/bin/」ディレクトリにファイル名「test.py」でスクリプトを作成

# cd /usr/local/bin/
# mkdir /test
# vim test.py

test.pyの中身(Python3.5用)

#!/usr/bin/env python
# -*- coding: utf-8 -*-


from time import sleep
from datetime import datetime


while True:
    now = datetime.now()
    now_str = now.strftime('%Y-%m-%d %H:%M:%S')
    print('[%s] %s' % (now_str, 'unko'))
    sleep(5)

スクリプトの機能としては、単純に時刻付きで「unko」を出力するだけ

supervisorの設定

先程作成したPythonスクリプトを動作させるため、supervisorの設定ファイルを編集する

# vim /etc/supervisord.conf

設定ファイル内に、コメントで設定のテンプレートがあるので、コピペで以下の内容をファイルの最後に追記した。

[program:unko]
command=/usr/local/lib/anaconda3/bin/python /usr/local/bin/test.py
;priority=999                ; the relative start priority (default 999)
autostart=true              ; start at supervisord start (default: true)
autorestart=true            ; retstart at unexpected quit (default: true)
startsecs=10                ; number of secs prog must stay running (def. 10)
startretries=3              ; max # of serial start failures (default 3)
;exitcodes=0,2               ; 'expected' exit codes for process (default 0,2)
;stopsignal=QUIT             ; signal used to kill process (default TERM)
;stopwaitsecs=10             ; max num secs to wait before SIGKILL (default 10)
;user=chrism                 ; setuid to this UNIX account to run the program
log_stdout=true             ; if true, log program stdout (default true)
log_stderr=true             ; if true, log program stderr (def false)
logfile=/var/log/supervisor/unko.log    ; child log path, use NONE for none; default AUTO
;logfile_maxbytes=1MB        ; max # logfile bytes b4 rotation (default 50MB)
;logfile_backups=10          ; # of logfile backups (default 10)

commmandのキーに、実行するコマンドを記載

自分の環境ではAnaconda3を利用しているので、Anaconda3のpythonコマンドのフルパスを記載している

supervisorハマった点

CentOSyumでインストールされるのは2.x系

インターネットで調べると3.x系の設定方法が結構見つかるけど、それを書いてしまうともちろん動作しない

supervisorで使用できる設定は、2.x系・3.x系両方ともコメントでテンプレート化されている感じ

別の言い方をすると「/etc/supervisord.conf」のコメントに無い設定は、多分使えない

自分は以下の点で結構ハマった

  1. [include]セクションが使えない
  2. 環境変数がほとんど設定されていない状態
  3. カレントを設定する「directory」キーが使えない
  4. 変数(?)が使えない

1. [include]セクションが使えない

色んな所で書かれている設定だけど

[include]
files = /etc/supervisord.d/*.ini

これはsupervisor2では使えない・・・

supervisor3ではコメント化されてるけど、supervisor2はコメントに無いじゃないですかー

2. 環境変数がほとんど設定されていない状態

自分が使用したsupervisor2では、環境変数が設定されていなかった

正確に言うとPATHの中身が「/sbin:/bin:/usr/sbin」って感じ

なので自分のようにデフォルトではないpythonを使わないときにうまくいかなかった

「/etc/profile」、「/etc/profile.d/*.sh」の設定を使うためには、commandの設定を変更する

↓設定例

command=bash -c 'python /usr/local/bin/test.py;'

3. カレントを設定する「directory」キーが使えない

supervisor2では「directory」キーが使えない(supervisor3では使えるし、コメントにも書いてある)

カレントを変更する場合は、commandの設定を工夫する

↓例

command=bash -c 'cd /usr/local/bin; python /usr/local/bin/test.py;'

bash -c」便利やな

4. 変数(?)が使えない

supervisorの設定を調べると、時々以下のような設定を見ることがあった。

[program:sample]

logfile=/var/log/supervisor/%(program_name)s.log    ; child log path, use NONE for none; default AUTO
  ```

supervisor3.xでは%(program_name)sに[program:sample]のsampleが自動置換されるのだろうが、supervisor2.xでは置換されなかった。



supervisorでスクリプトを起動

supervisorを起動する

# service supervisord start
supervisord を起動中:                                      [  OK  ]

起動状態の確認

# supervisorctl status
unko RUNNING    pid 6647, uptime 00:00:56

ここで何も表示されていないときは、(ほぼ)設定ファイルに問題がある

スクリプトがすぐ落ちる場合は、別のメッセージが表示される

ログファイル(/var/log/supervisor/unko.log)の中身の確認

[2017-01-28 23:15:17] unko
[2017-01-28 23:15:22] unko
[2017-01-28 23:15:27] unko
[2017-01-28 23:15:32] unko
[2017-01-28 23:15:37] unko

supervisorでpythonをデーモン化することができた。

今は別のスクリプトを起動させているが、1日経って特に今のところ問題なし

ログファイル周りの設定をデフォルトのまま使っているので、次はsupervisorのログファイル周りを調査しないといけませんな