ジョブカンの打刻をseleniumで自動化してGoogleCloudFunctionsにデプロイしたけど結構大変だった

#はじめに
ジョブカンの打刻をseleniumで自動化してGoogleCloudFunctionsにデプロイしたんですが
その際にハマったポイントなど記載します。

<!--
#簡単に自己紹介
東京のSIer勤務
RPAエンジニア →　AI系ソリューションのPoC導入支援等

Twitterもやってます↓
https://twitter.com/m_schna2
-->

#フロー図
以下のような構成になってます。
実際やってみるとGoogleDriveとCloudFunctionsの連携に結構時間がかかりました。
![スクリーンショット 2020-09-22 18.23.58.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/408367/57646a66-f38e-ec5d-27a2-932e69d7d516.png)

#全体のコード
id pw urlを適宜変更してください。

```pthon:main.py
from selenium import webdriver
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.chrome.options import Options
import jpholiday
import datetime
import requests, json
import os, time
from pydrive.auth import GoogleAuth
from pydrive.drive import GoogleDrive
import shutil

def punchClockWrapper(request):
  def isBizDay():
    dt_now = datetime.datetime.now()
    dt_now = utc_to_jst(dt_now)
    DATE = dt_now.strftime('%Y%m%d')
    Date = datetime.date(int(DATE[0:4]), int(DATE[4:6]), int(DATE[6:8]))
    if Date.weekday() >= 5 or jpholiday.is_holiday(Date):
      return 0
    else:
      return 1

  def punchClock():
    #定数
    url_jobcan = 'https://id.jobcan.jp/users/~~~~~~~~~~'
    id = '~~~~~~~~~~~~~'
    pw = '~~~~~~~~~~~~~'

    #オプション設定設定
    options = Options()
    options.add_argument('--headless')
    options.add_argument('--disable-gpu')
    options.add_argument('--window-size=1280x1696')
    options.add_argument('--no-sandbox')
    options.add_argument('--hide-scrollbars')
    options.add_argument('--enable-logging')
    options.add_argument('--log-level=0')
    options.add_argument('--v=99')
    options.add_argument('--single-process')
    options.add_argument('--ignore-certificate-errors')

    #GUIをバックエンド操作
    options.binary_location = os.getcwd() + "/headless-chromium"    
    driver = webdriver.Chrome(os.getcwd() + "/chromedriver", options=options) #Chromeを動かすドライバを読み込み

    #ジョブカンを開く
    driver.get(url_jobcan)

    #email入力
    text = driver.find_element_by_id("user_email")
    text.send_keys(id)

    #パスワード入力
    text = driver.find_element_by_id("user_password")
    text.send_keys(pw)

    #ログインクリック
    btn = driver.find_element_by_name("commit")
    btn.click()

    #打刻ボタンクリック
    btn = driver.find_element_by_id("adit-button-push")
    btn.click()

    #8秒待機
    time.sleep(8)

    #ウィンドウを閉じる
    driver.quit()

  def toSlack():
    WEB_HOOK_URL = "https://hooks.slack.com/services/~~~~~~~~~"
    dt_now = datetime.datetime.now()
    dt_now = utc_to_jst(dt_now)
    dt_punch = dt_now.strftime('%Y年%m月%d日 %H:%M:%S')
    requests.post(WEB_HOOK_URL, data = json.dumps({
      'text': str(dt_punch)+u' 打刻完了',  #通知内容
      'username': '~~~~~~',  #ユーザー名
      'icon_emoji': u':smile_cat:',  #アイコン
      'link_names': 1,  #名前をリンク化
    }))

  def utc_to_jst(timestamp_utc):
    timestamp_jst = timestamp_utc.astimezone(datetime.timezone(datetime.timedelta(hours=+9)))
    return timestamp_jst

  def writeLog(message):
    dt_now = datetime.datetime.now()
    dt_now = utc_to_jst(dt_now)
    dt_punch = dt_now.strftime('%Y年%m月%d日 %H:%M:%S')
    with open(log_path, 'a') as f:
      print(str(dt_punch)+u'  '+message, file=f)

  def downloadLog():
    f = drive.CreateFile(drive_param)
    f.GetContentFile(log_path)

  def uploadLog():
    f = drive.CreateFile(drive_param)
    f.SetContentFile(log_path)
    f.Upload()

  #定数設定
  log_path = "/tmp/punch_clock.log"
  drive_param = {
    'parents': [{
        'id':'~~~~~~~~~~~'
    }],
    'id': '~~~~~~~~~~~',
    'title': 'punch_clock.log'
  }
  #dir移動(settings.yamlを実行dirから読み込むため、デフォルトはカレントdir)
  #参照と書き込み権限があるディレクトリに格納する必要がある
  os.chdir(os.path.dirname(os.path.abspath(__file__)))
  shutil.copy2("credentials.json","/tmp/credentials.json")
  #認証
  gauth = GoogleAuth()
  gauth.CommandLineAuth()
  drive = GoogleDrive(gauth)
  #以下メイン処理
  downloadLog()#ログをGoogleDriveからダウンロード
  writeLog('処理開始') #開始ログ
  flg = isBizDay() #平日判定 (平日：1、休日：0)
  if flg == 1 :
    punchClock() #打刻
    toSlack() #スラック通知
    writeLog('打刻完了') #終了ログ
  else :
    writeLog('祝祭日のため打刻は行いませんでした') #終了ログ
  uploadLog() #ログをGoogleDriveにアップロード
  return '完了'
```


#すんなりいけた点
##selenium
メインの打刻処理(PunchClock)です。
コードでは打刻後に8秒固定で待機してるのが気になりますが打刻に失敗したことは無いので多分大丈夫です。

##Slack通知
webhookAPIを使って通知してます。
API設定は以下URLに手順があります。
https://slack.com/intl/ja-jp/help/articles/115005265063-Slack-%E3%81%A7%E3%81%AE-Incoming-Webhook-%E3%81%AE%E5%88%A9%E7%94%A8#incoming-webhook-u12398u35373u23450

#大変だった点
##GoogleCloudFunctions(GCF)にデプロイ
デプロイ方法ですがコードや必要なライブラリ・モジュールをすべて同一フォルダに入れて以下コード等でアップロードします。

```
gcloud functions deploy punchClockWrapper --runtime python37 --trigger-http --region asia-northeast1 --memory 512MB
```

ここらへんは以下URLを参考にしています。
https://blowup-bbs.com/gcp-cloud-functions-python3/
##GoogleDriveへのログ追記(PyDrive利用)
GoogleDriveにログを追記するには
前回までのlogをダウンロード→追記→アップロード
って操作が必要になるんですがGoogleDriveではファイルidで管理されているので同一名でアップロードしてもデフォルトだと上書きではなく同じ名前ですが別ファイルとしてアップロードされます。
そこで親フォルダのidとlogファイルのidを指定することで上書きできるんですが、logファイルのidは通常のGUI操作では取得できない？ようで以下コードを回してidを確認しました。

```
from pydrive.auth import GoogleAuth
from pydrive.drive import GoogleDrive
import pprint

#認証
gauth = GoogleAuth()
gauth.CommandLineAuth()
drive = GoogleDrive(gauth)

#fileデータ取得
file_id = drive.ListFile({'q':'title="punch_clock.log"'}).GetList()[0]['id']
f = drive.CreateFile({'id':file_id})

#ファイルメタデータ表示
f.FetchMetadata()
pprint.pprint(f)
```

ちなみにフォルダidについてはブラウザで開いてurl部分から確認可能です。以下のリンクにも確認方法が記載されていました。
https://tonari-it.com/gas-google-drive-file-folder-id/

##PyDriveとGCFの組み合わせ
PyDriveはGoogleDriveAPIのラッパーライブラリです。
最初は対話的な認証が要求されますが認証が成功した次点でjsonが作成されて2回目以降はそのjsonを勝手に参照してくれるのでコードに組み込んでGoogleDriveの自動化が可能です。コード事態も少なくてとてもすっきりしています。

```
#認証
gauth = GoogleAuth()
gauth.CommandLineAuth()
drive = GoogleDrive(gauth)
```

ただ認証時の挙動なんですが
カレントディレクトリのsettings.yaml参照
→save_credentials_fileに指定されているパスを参照
→jsonが存在すれば認証成功してjsonを上書き、なければcodeで認証を要求してcodeが入力されれば同じく指定パスにjson作成
となる点は意識しておく必要があります。

もちろんGCFにデプロイ後に対話的な認証などできません。
なのでローカルで事前に認証しておいてjsonファイルを作成しておきデプロイするフォルダに一緒に格納しておくのですが、GCFでは書き込み可能なディレクトリは/tmpに限定されています。
そして認証成功時にもjsonは書き込み（上書き）されるのでsave_credentials_fileには/tmp/~~~.jsonというパスを指定しなければなりません。
ということでコードの中(以下コード)ではcredentials.jsonをデプロイディレクトリから/tmpにコピーしています。そして一緒にデプロイしたsettings.yamlではsave_credentials_fileの項目で/tmp/credentials.jsonを指定しています。
ちなみにPyDriveのsettings.yamlは必ずカレントディレクトリになければいけないのでファイル実行ディレクトリに移動しています。

```
#dir移動(settings.yamlを実行dirから読み込むため、デフォルトはカレントdir)
#参照と書き込み権限があるディレクトリに格納する必要がある
os.chdir(os.path.dirname(os.path.abspath(__file__)))
shutil.copy2("credentials.json","/tmp/credentials.json")
```

##デフォルトではログのタイムスタンプがズレる
GCFで起動するワーカーのデフォルトリージョンはus-central1で日本時間から9時間ずれているためログ出力とSlack通知時に記載する時刻を以下関数で補正しています。
(今回asia-north1でデプロイしてるはずなんですがそれでもなぜかズレてしまいます……)

```
def utc_to_jst(timestamp_utc):
  timestamp_jst = timestamp_utc.astimezone(datetime.timezone(datetime.timedelta(hours=+9)))
  return timestamp_jst
```

#今後の課題
 - pwを埋め込んでいる
個人で使用しているだけなので優先度は低いですが時間があれば改善したいです。

 - 呼び出し元に制限をかけていない
現状GCFのトリガーとなるURLさえわかれば誰でも私の打刻が可能な状態です！
ただcloud schedulerの機能で制限をかけることができそうなので時間があれば修正します。

#参考URL
https://stackoverflow.com/questions/51487885/tmp-file-in-google-cloud-functions-for-python
https://blowup-bbs.com/gcp-cloud-functions-python3/
https://dev.classmethod.jp/articles/python-time-string-timezone/
https://qiita.com/wezardnet/items/34b89276cdfc66607727