Python
Selenium
PhantomJS
Slack
slackbot

もう毎日の打刻めんどいからSlack botにやってもらおう ―― その2

現在、セキュリティーが厳しい現場にいるため、外部サイトへのアクセスが制限されています。
そのため、今まではPCからやっていた打刻がスマートフォンでしかできなくなり、あの小さい画面でユーザーIDを入力して、パスワード、種別を選択して。。。。
めんどくさい
ということでSlack botにやってもらいます。

という記事を前に書きました。
https://qiita.com/kentakozuka/items/eedebcc4275c894c45a3

今回はその改良版についての記事になります。
改良版といってもまるっとほぼ変えてしまったのですが。。。

コードはgithubに載せています。
https://github.com/kentakozuka/dakoku_v2

やったこと

打刻

Slackで「おは」を打つと出勤打刻をして、「おつ」を打つと退勤打刻して、「した」と打つと退勤打刻+残業申請をします。
slackbotで投稿された文字を判定し、「おは」と「おつ」と「した」ならSeleniumでWebサイトに打刻しに行き、結果をスクショで返します。

ユーザー情報登録・変更

Slackでボットに対してメンションを送るとCLIっぽくユーザー登録変更ができます。

ライブラリ

主要なライブラリはslackbot、Slacker、Selenium、parseです。

出勤時:「おは」と投稿された場合

メッセージリスナー

@listen_to('おは')
def start_work(message):
    ''' 出勤打刻をする '''
    mh = MessageHandler( \
            message, \
            Ts.CrawlRunner(Ts.StartStampCrawler()), \
            const.msg_start_stamp, \
            const.msg_start_greeting)
    mh.run_crawler()

メッセージハンドラー

class MessageHandler(Handler):
    '''メッセージハンドリングクラス'''

(中略)

    def run_crawler(self):
        ''' クロール共通処理 '''
        # ユーザー名を取得
        send_user = self.get_send_user()
        user_info = super().get_user_info(send_user)

        # チャンネル名を取得
        channel_info = self.get_channel_info()

        # ユーザー情報が存在すれば打刻処理を行う
        if user_info:
            if not super().is_all_fields_filled(send_user):
                self.message.reply('設定されていない項目があるため打刻処理を実行できません。')
            else:
                # クロール
                img_path = self.crawlrunner.run(user_info)
                # スクショを投稿
                self.post_img(img_path)
        else:
            self.message.reply(self.greeting_msg.format(send_user))

        # disconnect
        self.cursor.close()
        self.connect.close()

メッセージが「おは」場合、メッセージハンドラーを実行します。ハンドラーにはクローラークラスのインスタンスを呼び出して、クロールを実行させます。ハンドラー内ではリスナーのコールバックの引数で受け取った情報からユーザー名を取りだし、自分(と同期)のユーザー名と一致し、かつDBに情報がある場合はクロールを実行します。別の人ならただ返信します。

打刻しにいく

class AbsCrawler:
    '''
    Strategyパターン
    クロール実行抽象クラス
    '''

    def __init__(self):
        self.user_info = None
        # ドライバを生成
        self.driver = self.init_driver()
        # 最大の待機時間(秒)を設定
        self.wait = WebDriverWait(self.driver, 20)

    def set_user_info(self, user_info):
        ''' ユーザー情報のセッター '''
        self.user_info = user_info

    @abstractmethod
    def run(self):
        pass

    #######################################
    # ページ遷移
    #######################################

    def proc_with_page_move(self, func):
        ''' ページ読み込みを含む処理 '''
        # 関数実行
        func()
        # ページが読み込まれるまで待機
        self.wait.until(ec.presence_of_all_elements_located)

    def go_page(self, page_url):
        ''' 指定した画面に遷移 '''
        self.driver.get(page_url)

    def go_login_page(self):
        ''' ログイン画面に遷移 '''
        page_url = const.LOGIN_URL
        self.go_page(page_url)

    def go_time_stamp_page(self):
        ''' 打刻画面に遷移 '''
        page_url = const.TIME_STAMP_URL
        self.go_page(page_url)

(中略)

    #######################################
    # フォーム送信
    #######################################

    def login(self):
        ''' ログインフォーム '''
        loginid = self.driver.find_element_by_id('id')
        password = self.driver.find_element_by_id('pass')

        loginid.send_keys(self.user_info['daim_id'])
        password.send_keys(self.user_info['daim_password'])

        self.driver.find_element_by_name("form01").submit()

    def stamp_start(self):
        ''' 出勤打刻フォーム '''
        self.set_option(const.FORM_NAME_11, self.user_info['office_id'])
        self.set_option(const.FORM_NAME_16, self.user_info['department'])
        self.set_option(const.FORM_NAME_12, '1')

        self.driver.find_element_by_name("form01").submit()
class StartStampCrawler(AbsCrawler):
    ''' 出勤打刻クラス '''
    def __init__(self):
        super().__init__()

    def run(self):
        # ログインページ遷移
        super().proc_with_page_move(super().go_login_page)
        # ログイン実行
        super().proc_with_page_move(super().login)
        ## 打刻画面遷移
        super().proc_with_page_move(super().go_time_stamp_page)
        ## 出勤打刻実行
        super().proc_with_page_move(super().stamp_start)
        # スクショ
        img_path = super().take_screen_shot()
        # ドライバを閉じる
        self.driver.quit()
        # スクショのパスを返す
        return img_path

クローラーの実行でやることは単純で、ブラウザでやっていることと同じことをSeleniumにやらせるだけです。
ポイントとしては以下で余裕をもった時間を設定しておかないとDOMをすべて解析する前にタイムアウトと判断されてエラーがでることです。

# 最大の待機時間(秒)を設定
self.wait = WebDriverWait(self.driver, 20)

また、ドライバは”普通の”ブラウザ(Chromeとか)を設定することもできますが、その場合ブラウザが立ち上がって処理が進むのでGUIがない環境ではPhantomjsを設定しないと動きません。

driver_path = <ドライバのパス>
user_agent = <ユーザーエージェント>
dcap = {
    "phantomjs.page.settings.userAgent" : user_agent,
    'marionette' : True
}
driver = webdriver.PhantomJS(
        executable_path=driver_path,
        desired_capabilities=dcap)

return driver

ちなみに、遷移した最後の画面のスクショをslackに送信するのですが、slackbotでは画像の送信ができないため、一度画像ファイルとして保存したスクショをSlackerで送信しています。

# スクショを投稿する
slacker = Slacker(<API-トークン>)
slacker.files.upload( \
            <ファイルパス>, \
            channels=[<投稿するチャンネル名>], \
            title=self.finish_msg.format(<タイトル>)

ユーザー情報の登録・変更

コマンドのパーサについて、今回は簡単なコマンドのパースだったので、以下のライブラリを使ってパターンマッチで実装しました。

https://github.com/r1chardj0n3s/parse

この部分は自信がなかったのでpytestを使ってテストしながら実装しました。やっぱり単体テストすると安心しますね。

実際のコマンドの使いかたはこんな感じです。

使い方: @dakoku_bot コマンド

Slack上で動作する打刻ボット

コマンド:
    show    各種情報を表示します。

            サブコマンド:
                user            ユーザー情報を表示します。
                office          部署情報を表示します。

            入力例 --ユーザー情報を表示する:
                @dakoku_bot show user

    add     ユーザー情報を新規登録します。

            入力例:
                @dakoku_bot add

    set     各種情報を設定します。

            オプション:
                --uid=string    stringにはユーザーID (user id) を入力します。
                --pw=string     stringにはパスワード (password) を入力します。
                --dep=string    stringには所属部署 (department) の番号を入力します。
                --atime=HH:MM   HH:MMには出勤時間 (attendance time) を入力します。
                --ltime=HH:MM   HH:MMには退勤時間 (leaving time) を入力します。
                --cow=string    stringには残業理由 (cause of overtime-work) を入力します。

            入力例 1 --まとめて設定する:
                @dakoku_bot set --uid=0123456789 --pw=mypassword --atime=09:00 --ltime=18:00 --cow=作業過多のため

            入力例 2 --残業理由のみ変更する:
                @dakoku_bot set --cow=作業過多のため

    del     ユーザー情報を削除します。

            入力例:
                @dakoku_bot del

やってみて思ったこと

前回の記事にも書いたのですが、毎日が便利になりました。やっぱり、ここが一番うれしいですね。
前回はScrapyを使っていたのですが、どうしてもある画面でセッションが取得できなくて、かなりハマってしまいました。試行錯誤を重ねたのですが結局できず、Seleniumにすべてを書き換えることになりました。実際、簡単なクローリングならSeleniumの方が格段に楽に書けると感じました。ScrapyでもPhantomjsは使えるみたいです。
パーサー部分はかなり汚いコードになってしまいました。やっぱりパーサーは宣言的に書くべきなんでしょうかね。

最後に便利なライブラリを作っているみなさま、ありがとうございます!