gologiusの巣

プログラミング、モデリングなどのメモです。誰かの役に立てるとうれしいです。

メール監視システムを作る その2(Pythonスクリプト編)

前回サーバー構築と設定をしました。 gologius.hatenadiary.com

今回はメールチェックとメール送信用のスクリプトを書いていきます。

使うスクリプト

GMailAPI を叩くPythonスクリプトです · GitHub

実行環境構築

(1) Google API を有効にしておく(下記URL参照)

スクリプト実行に必要なJSONファイルもDLできるはずです。

ゼロからはじめるPython(22) PythonでGmailのメールを確認しよう | マイナビニュース

(2) 以下のコマンドを実行し、モジュールをインストール

pip3 install google-api-python-client
pip3 install oauth2client
pip3 install httplib2

(3) 認証用関数user_auth()を実行する(1回のみ)

(4) JSONファイルcredentials-gmail.jsonが生成されます

メールの送受信

# -*- coding: utf-8 -*-
import httplib2
import base64
import traceback
from googleapiclient import discovery
from oauth2client import client
from oauth2client import tools
from oauth2client.file import Storage
from email.mime.text import MIMEText
from email.utils import formatdate
 
SCOPES = 'https://mail.google.com/' # Gmail権限のスコープを指定
CLIENT_SECRET_FILE = 'client_id.json' # Google AP管理サイトからダウンロードした権限ファイルのパス
USER_SECRET_FILE = 'credentials-gmail.json' # ユーザーごとの設定ファイルの保存パス
USE_ADDR = "test@gmail.com" # 使用するメールアドレス

#認証用(開発環境を整える際に、初回に一回実行する)
def user_auth():
    store = Storage(USER_SECRET_FILE)
    credentials = store.get()

    # ユーザーが認証済みでない場合 新規認証する
    if not credentials or credentials.invalid:
        flow = client.flow_from_clientsecrets(CLIENT_SECRET_FILE, SCOPES)
        flow.user_agent = 'Python Gmail API'
        credentials = tools.run_flow(flow, store, None)
        print('認証結果を保存しました:' + USER_SECRET_FILE)
 
    return credentials

#API使用準備
def get_service():
     credentials = user_auth()
     http = credentials.authorize(httplib2.Http())
     service = discovery.build('gmail', 'v1', http=http)
     return service

#N件メールを取得
def get_messages(maxnum):
    service = get_service()
    messages = service.users().messages()
    msg_list = messages.list(userId='me', maxResults=maxnum).execute()
    print("メールサーバーからの受信完了")
    
    payloads = []
    # 取得したメッセージの一覧を表示
    for msg in msg_list['messages']:
         topid = msg['id']
         payload = messages.get(userId='me', id=topid).execute()
         payloads.append(payload)    
        
         # メール情報を確認 
         item = mail.mail()
         headers = payload['payload']['headers']
         for header in headers:
             if header['name'] == 'Date':
                 print(header['value']) #受信日?
             if header['name'] == 'From':
                 print(header['value']) #from
             if header['name'] == 'To':
                 print(header['value']) #to
             if header['name'] == 'Cc':
                 print(header['value']) #cc
             if header['name'] == 'Subject':
                 print(header['value']) #件名
        
         print(payload['snippet']) #本文の概要(※全文は入らない)
         
    return

# メールを送信する
def send_message(to, subject, body):
    
    #メール作成
    message = MIMEText(body)
    message["from"] = USE_ADDR
    message["to"] = to
    message["subject"] = subject
    message["Date"] = formatdate(localtime=True)

    byte_msg = message.as_string().encode(encoding="UTF-8")
    byte_msg_b64encoded = base64.urlsafe_b64encode(byte_msg)
    str_msg_b64encoded = byte_msg_b64encoded.decode(encoding="UTF-8")
    rawdata =  {"raw": str_msg_b64encoded}
    
    #送信
    service = get_service()
    try:
        service.users().messages().send(
            userId="test@gmail.com",
            body=rawdata
        ).execute()            
        print("メール送信完了")
    except:
        print("メール送信エラー")
        traceback.print_exc()
        
    return 
    

gmail_mailbox.py

  • get_messages()でメール情報を取得できます
  • send_message()でメール送信できます

受信できれば後は好きに弄れば、メール監視スクリプトの完成です。

main.pyを叩くようなバッチを作成し(start.sh)、crontab -eにてそのバッチを叩くように設定します(前回記事参照)。

# -*- coding: utf-8 -*-
# main.py

import datetime

import gmail_mailbox

def job():
    # メール取得+チェック
    mails = gmail_mailbox.get_messages(40)
    print('メール取得完了')
    
    isError = checkError(mails) #適当にチェック
    
    # 現在時間と一緒に、処理結果を表示
    nowtime = datetime.datetime.now();
    timestr = nowtime.strftime('%Y/%m/%d %H:%M:%S')   
    body = u"<チェック結果>\n"
    body += u"isError " + str(isError) + "\n"    
    
    if (isError) == True:
        print("")
        print("【結果】 [" + timestr + "] 何か検知しています")
        print("")
        
        gmail_mailbox.send_message(u"example@gmail.com", u"システムエラー", body) 
        
    else:
        print("")
        print("【結果】 [" + timestr + "] 何もありませんでした")
        print("")
    
    return

job()
#!/bin/sh
cd  `dirname ${0}`
date
datestr=`date '+%Y%m%d'`

echo begin mail checker
python3 main.py >> log/${datestr}.log 2>&1
echo end mail checker

main.pyでは、メールを40通見て、エラーと判定されるとメールを送信するようにしています。

ポイント

  • from apiclientからfrom googleapiclientにモジュール名が変更されています。 古い記事を参考にしている場合、ここでエラーになる場合があります。
  • payload['snippet'] はメールの概要を見るためのものです。実際に全文見ようとすると、 別の項目エンコードする必要があります
  • 調査中ですが、メールを重複して受信する場合があります
    • 恐らく、独自にラベルを設定していると、受信トレイ分とラベル分、二つのメールを受信してしまうようです

メール監視システムを作る その1(サーバー用意編)

要件としては以下の二つです

  • メールを監視したい。 GMailのルールだと限界感があるので、プログラムでゴニョゴニョやりたい。
  • 特定条件ならエラーメールを送信したい。

メールはGmailのメールアドレス(一つ)が対象なので、 GmailAPIを叩くPythonスクリプトを作成します。

作ったスクリプトはサーバー上で一定時間ごとに動かします。

サーバーを用意する

これを買いました。

https://www.amazon.co.jp/gp/product/B07CSN3CLY/ref=oh_aui_detailpage_o03_s00?ie=UTF8&psc=1www.amazon.co.jp

Win10が入ってました(OSなし版もあるみたいです) が容赦なくUbuntu 18.04.1 LTSを上書きインストールします。 デュアルブートにしてもいいんですけど、Win10はメインPCとノートPCに既にあるので今回はなしで。

なぜOSなし版を買わなかった・・・(3000円くらい安い、みたいな意見をどこかで見ました)

環境構築

Ubuntuのインストール方法や、SSHの構築方法は省略します。 いろんな記事があるので適用に参考にすればいいと思います。

  • GmailAPIを叩くスクリプトPythonで作成(次回紹介)
  • 上記Python環境を叩くためのPython環境(今回紹介
  • 一定時間ごとにバッチを叩く→cronを使用(今回説明

Python環境構築

Ubuntu 18.04.1 LTSPythonが既にインストールされています。 素晴らしいですね。

ただし、Python2系とPython3系が両方入っていますpythonコマンドで実行されるのは、デフォルトは2系です(python --versionで調べられます)

パッケージもPython2系とPython3系で管理が分かれています。 ですので、パッケージインストール時はpip install hogepip3 install hogeか、 どちらのバージョンに入れるのかを、明示的に指定する必要があります。

実行時も同様にpython hoge.pypython3 hoge.pyかどちらで実行するか、 明示的に指定する必要があります。

cron環境+動作設定

cronはスケジュール実行する仕組みです(Windowsでいうタスクスケジューラー) 有名な仕組みなので、色々情報もネット上にありますのでここで詳しく説明はしません。

ただですね、Ubuntu 18.04.1 LTSの情報が全然なく(パス関連が微妙に違う)、 困りました。

cronログ出力設定

デフォルト設定ではcronの実行ログが出力されず、cronが動いているか分からないので、 ログを出力するようにします。

/etc/rsyslog.d/50-default.conf

下記がコメントアウトされているのでコメントイン

cron.*    /var/log/cron.log

スケジュール設定

crontab -e

cron設定用のエディタが開くので、以下のような感じで記述 文法自体は他のサイトを参照してください。

crontabの書き方 | server-memo.net

#10分ごとにメールチェックをするバッチを叩きにいく。絶対パスにすること
*/10 * * * * /home/username/python/mail_checker/start.sh

start.shの中身はこんな感じ。実行日ごとにログファイルを作成し、追記しています。

#!/bin/sh
cd  `dirname ${0}`
date
datestr=`date '+%Y%m%d'`

echo begin mail checker
python3 main.py >> log/${datestr}.log 2>&1
echo end mail checker

GMailAPI を叩くPythonスクリプトです · GitHub

設定後、cronをreloadrestartすると、設定スケジュールどおりに実行される。 ※どちらが良いかは不明

/etc/init.d/cron restart
もしくは
/etc/init.d/cron reload

実行されているかは

/etc/init.d/cron status

で確認可能です。

まとめ

サーバー上で一定時間ごとにPythonスクリプトを起動させる準備が整いました。 次回はPythonでGmailAPIを叩き、受信ボックス内のメールを見ていきます

【Python】datetime.strptime で エラー 「unconverted data remains:」 が発生した場合

背景

Gmailの取得をPythonで開発していました。

取得できる受信日は以下のようなフォーマットで取得できます

Fri, 9 Nov 2018 20:37:10 

String型なのでDate型で色々したいわけですよ。

Pythonでは以下のような方法で「文字列→Date型(厳密には違いますが)」ができます。

lastTimeStr = 'Fri 9 Nov 2018 20:37:10 '
lastitime = datetime.datetime.strptime(lastTimeStr, "%a %d %b %Y %H:%M:%S")

※dateutilなど別の方法でもできます。

問題

以下のエラーが発生しました

ValueError: unconverted data remains:  

なぜでしょうか

原因と解決方法

  • 末尾にスペースが入っていたから
  • カンマが入っていたから

なので以下のように文字を消してあげると正常に動きます。

lastTimeStr = 'Fri, 9 Nov 2018 20:37:10 '
lastTimeStr = lastTimeStr.replace(",", "") #カンマ削除
if lastTimeStr[-1] == " ":
    lastTimeStr = lastTimeStr[:-1] #末尾にスペースが含まれていれば無視する

lastitime = datetime.datetime.strptime(lastTimeStr, "%a %d %b %Y %H:%M:%S")

ちなみに上記のエラー、remains:の後に問題になっている文字が表示されるようですが、 スペースなので、表示されていないように見えていたわけですね。

【JavaScript】JSON.parse() がエラーになる

事象

下記のようなエラーが発生する

JSON.parse: unexpected character at line 1 column 1 of the JSON data

文字列(JSON形式)をJSON.parse()でJSのオブジェクト型に変換する際に、 JSON構文エラーになっている模様。 構文チェッカーでチェックしても普通に問題はない。

JSON Pretty Linter - JSONの整形と構文チェック

環境

  • サーバー側(PHP)とフロントエンド側(JavaScriptJQuery)でAjax的なことをする
  • サーバー側にPOSTでアクセスすると、結果がJSONで返却される

以下JavaScript側サンプルコード

//サーバー上のファイルを削除する
function cancel(){
    
    if(!confirm('サーバー上のファイルを削除します。よろしいですか?')){
        return false;
    }

    //サーバーに受け渡し
    $.ajax({
        type: 'POST',
        url: 'php/cancel.php' <<<<< ★
    }).done((result)=>{
        showResultMsg(result);  
    });
}

function showResultMsg(json){
    
    console.log(json);
    var result = JSON.parse(json); // <<<<<<こいつがエラーを吐く
        
    if (result.result != 'SUCCESS'){
        //エラー時
        $('#result').attr('class','warning');                    
        console.log(result);
    }
    else {
        //成功時
        $('#result').attr('class','');                   
    }
    $('#result').text(result); 
}

原因

サーバー側のcancel.phpが原因。 他ファイルは全てUTF-8で統一されていたが、こいつだけUTF-8-BOMになっていた。 なので、'echo`して返却したJSONがおかしいことに なっていたっぽい

BOMとは

uxmilk.jp

感想

クソハマったのでつらかった。

【コマンドプロンプト(cmd)】AAを表示する

以下のようなスクリプトを作りましたが、その作り方

コマンドプロンプト(cmd.exe)で大きく「本番」と「検証」の文字を表示します · GitHub

f:id:gologius:20180616094423p:plain

画像変換ツールをDL

Unix系だとFIGlet というツールがapt-getで使えるようです。 Win版も配布されていましたが、Win10では使用できませんでした。

なので以下のツールを使用しました 文字絵エディターの詳細情報 : Vector ソフトを探す!

使い方は簡単なので割愛

編集

正直あとは適当に編集してやればできるのですが、私の方法を記載しておきます。

  • notepad++を使用します(そこそこのテキストエディタならなんでもよいですが)
  • Alt+Shitで行、列ごとの選択ができます
  • 先頭行にechoを追加します

まとめ

文字数少ないね ( ^ω^)・・・

【Python】 loggerのログが重複する

ログが再起動のたびに増えていくバグと、運命の出会いを果たしたのでメモします。

Pythonのログの取り方をお勉強しました

以下の記事で、ログの取り方について学びました。

ログ出力のための print と import logging はやめてほしい

超絶的に雑な解釈ですが、以下のようなものだと理解しました

  • print → エラーなのか警告なのかログなのか分からない。ログとる際に使うべきでない
  • logging → グローバル変数的な立ち位置。
  • logger → ローカル変数的な立ち位置。

※詳しくは元記事を参考にしてください・・・。

お、同じログがいっぱいでるぞ~

以下の記事で解決

uyamazak.hatenablog.com

再実行するたびにログが増えていく

上記の記事のようなログ用モジュールを作成して、実行する。

超具体的には、Anacondaで環境構築して、SpyderをIDEとして利用しており、 実行時にF5を使用すると、実行のたびにどんどん増える

# 一回目
[log info] ログテスト
# 二回目
[log info] ログテスト
[log info] ログテスト
#三回目
[log info] ログテスト
[log info] ログテスト
[log info] ログテスト

f:id:gologius:20180606214449p:plain

解決策

終了時に、killLoggers()のようにハンドラを全削除してあげます

from logging import getLogger, Formatter, FileHandler,StreamHandler, DEBUG, shutdown
from logging.handlers import RotatingFileHandler

loggers = {}

def getModuleLogger(moduleName):
    if moduleName is None:
        moduleName = __name__

    if loggers.get(moduleName):
        return loggers.get(moduleName)

    formatter = Formatter('[%(asctime)s | '
                          '%(name)s | '
                          '%(levelname)s] '
                          '%(message)s')

    streamHandler = StreamHandler()
    streamHandler.setFormatter(formatter)
    streamHandler.setLevel(DEBUG)

    fileHandler = RotatingFileHandler("download.log", maxBytes=5000, backupCount=3)    
    fileHandler.setFormatter(formatter)
    fileHandler.setLevel(DEBUG)

    logger = getLogger(moduleName)
    logger.setLevel(DEBUG)
    logger.addHandler(streamHandler)
    logger.addHandler(fileHandler)

    logger.propagate = False
    loggers[moduleName] = logger

    return logger

def killLoggers():

    for l in loggers:
        logger = loggers.get(l)
        for h in logger.handlers:
            logger.removeHandler(h)

    shutdown()

    return

これで重複出力はなくなります。

以上。

Selenium+Pythonにて、アラートが出る新規ウインドウに遷移したい

※Teratailで私が質問した問題を、結局自分で解決した際のメモです

Python - Selenium+Pythonにて、アラートが出る新規ウインドウに遷移したい(125969)|teratail

※2018/06/03追記

どうやらヘッドレスモード(GUI、ウインドウを表示しないモード)にすると、いちいち前のウインドウに遷移する必要がなくなるようです。 記事の最期の方に記載しておきます。

※2018/06/27追記 ↑とか書いちゃったのですが、実際には

  • 新規ウインドウが出るようなボタンをクリック
  • (新規ウインドウでアラートが出るまで待つ。)
  • switch_to_window()で新規ウインドウをアクティブにする

の流れで処理すると動くようです

やりたいこと

  • リンクをクリックする→新規ウインドウが開く(※ただしウインドウが開くと即アラートが出る)

という動きをSeleniumPythonで実現したいと考えています。

実際にはその先のボタンを押したいのですけどね。

下記のリンクで例を見ることが出来ます。

テストページ | gologiusのページ

問題点

調べているとどうも

  • アラートを消すためには、アラートが出ているウインドウに遷移しなければならない
  • ウインドウに遷移するためには、アラートを消さなければならない

と、デッドロックみたいな状況になっているようです。

以下ソースの★1、★2の部分です。

ソース

# -*- coding: utf-8 -*-
"""
Created on Wed Mar  7 22:16:01 2018

@author: 
"""

from selenium import webdriver
from selenium.webdriver.common.alert import Alert
from selenium.webdriver.support import expected_conditions
from selenium.webdriver.support.ui import WebDriverWait
from selenium.common.exceptions import TimeoutException

import datetime
import traceback

###########################################################################
def waitOpenWindow(driver, beforeWinNum, maxWaitSec = 10):
    """
    指定された時間まで、ウインドウが開くのを待つ
    """

    beginTime = datetime.datetime.now()
    endTime = beginTime + datetime.timedelta(seconds=maxWaitSec)

    while(datetime.datetime.now() <= endTime):

        afterWinNum = len(driver.window_handles[-1])
        if afterWinNum == beforeWinNum:
            continue
        elif  afterWinNum > beforeWinNum:
            #ウインドウ数が増える=新規ウインドウが開いた
            return True , "指定時間内に新規ウインドウが開きました"
        else:
            return False, "ウインドウが外部から閉じられた可能性があります"

    return False, "指定時間内に新規ウインドウが開きませんでした"

###########################################################################
def waitAlert(driver, maxWaitSec = 10):
    """
    指定された時間まで、アラートが開くのを待つ    
    """

    try:
        wait = WebDriverWait(driver, maxWaitSec)
        wait.until(expected_conditions.alert_is_present())
    except TimeoutException as time_e:
        return 0, "アラート表示待機中にタイムアウトしました"
    except Exception as e:
        print(traceback.print_exc)
        return -1, "アラート表示待機中に予期しないエラーが発生しました"

    return 1, "アラートが表示されています : " + Alert(driver).text

###########################################################################
#関数定義
def access():

    try :
        print("begin process")

        driver = webdriver.Chrome("chromedriver_win32\chromedriver.exe")
        driver.implicitly_wait(10)
        driver.get("https://gologius.github.io/test.html")

        print("画面名", driver.title)
        winNum = len(driver.window_handles)

        #リンクをクリック
        driver.find_element_by_link_text("javascriptによる小ウインドウ表示").click()

        #ウインドウ表示待機     
        print("画面遷移待機中")
        waitResult, msg = waitOpenWindow(driver, winNum)
        if waitResult == False:
            return False, "ウインドウ表示に失敗しました"
        print(msg)

        #新規ウインドウに遷移する ★1
        win = driver.window_handles[-1] #リストの最後=最後に開いたウインドウ
        driver.switch_to.window(win)
        print("画面名", driver.title)
        winNum = len(driver.window_handles)

        #アラート待機 ★2
        print("アラート表示待機中")
        resultCode, msg = waitAlert(driver)
        if resultCode == 1:
            Alert(driver).accept()
        print(msg)

        print("end process")

    except:
        print("予期しないエラーが発生しました")
        print(traceback.format_exc())
        #ログ出力
        driver.quit()

    return True, "アクセス成功"

###########################################################################
#実行

result, msg = access()
print(msg)

解決方法

ボタンクリック後、「遷移前のウインドウ」にdriver.switch_to_window(win)で遷移すると、 その後のアラート処理、ウインドウ遷移が実行できるようです

win1 = driver.window_handles[-1]

driver.find_element_by_link_text("javascriptによる小ウインドウ表示").click()

#遷移前のウインドウに遷移する
driver.switch_to_window(win1)

#アラート処理

#ウインドウ遷移
win2 = driver.window_handles[-1]
driver.switch_to_window(win2)

理由は不明ですが、推測するに、新規ウインドウが表示される際に、

  • 新規ウインドウが開く際に、そのウインドウがアクティブになろうとする
  • Alertのせいでアクティブになりきれない →無限ループの原因
  • 遷移前のウインドウをアクティブに(switch_to_window)する
  • Alert(driver).accept()なども実行可能になる

みたいな気がします。


※2018/06/03追記↓

ヘッドレスモードで起動すると、この話は無視できる・・・?

        chromeOptions = webdriver.ChromeOptions()
        chromeOptions.add_argument('--headless') #ブラウザのGUIが表示されなくなる 
        driver = webdriver.Chrome("hogehoge/chromedriver.exe", chrome_options=chromeOptions)

雑感

上記のような不具合を抜きにしてもSelenium便利。

ちなみに検証用サイトはgithubioで作成しました。 テストページ | gologiusのページ

JavaScript使えるのでこういう際にサクッと作れるので便利。 ただJSの書き方を忘れていたのはナイショ...(/ω\)