AWS LambdaでMOCO'Sキッチンの更新情報をつぶやくTwitter Botを動かす

2018/03/05 更新

MOCO’sキッチンのサイトの仕様が変更されたため、以下のコードでは動作しなくまりました。bot箇所については新しい記事を参考にしてください。AWS Lambdaでの設定はインターフェースの変更などが見受けられますが、おおまかな手順は変わっていません。


はじめに

平日の朝8時頃にMOCO’Sキッチンの本日のメニューをツイートするのが日課です。数年前、オリーブオイルの使い方が奇抜だと注目された時期に見始めたことがきっかけだと思います。

最近ではブームも一段落してネット上での盛り上がりも落ち着いてきましたが、その頃の名残なのか毎日のメニューを確認することで1日の始まりにリズムが付くような気がします。

日々のつぶやきは開始以来ずっと手動で行っていました。これが意外と面倒です。ブラウザを開いてサイトにアクセス、料理名をコピーしてTwitterアプリを開きツイート作成画面にペースト、ハッシュタグを付けてようやく送信です。この一連の流れを自動化できたら便利だなと今回のTwitter Bot作りを思い立ちました。

特定のサイトから情報を取得してツイートするにはどうすればいいのか思案していると、サーバーでスクリプトを書いてcronで定期的に実行するのが最適ではとの考えに至りました。スクリプトはWebスクレイピングやTwitter関連のライブラリが豊富なPythonで書くのが良さそうです。

簡単なスクリプトですのでわざわざサーバーを使うのは大仰だなと調べていると、去年の10月にAWS LambdaがPythonに対応したとの発表を目にしました(Amazon Web Services ブログ: 【AWS発表】AWS Lambdaのアップデート – Python, VPC, 実行時間の延長, スケジュールなど)。さらにCloudWatchと連携させることでcronのような動作も可能とのことです。

せっかくの新機能なので早速利用することにします。残念なのはAWS Lambdaの対応するPythonのバージョンが2.7のみだという点で、日本語文字列の処理などに気を使わねばなりません。 (17/04/19 追記) 2017年4月18日からPython 3.6が利用できるようになりました。以下、Python 3に対応するよう記述をアップデートしています。Python 2では動作しません。過去の履歴をご覧になりたい場合はレポジトリを確認ください。

以上を踏まえて、

  • Webページをrequestsで取得
  • BeautifulSoupでメニュー名を抜き出す
  • Tweepyを使ってそれをつぶやく

という流れのスクリプトをAWS LambdaにアップロードしてCloudWatchで定期的に実行するという方針を取りました。

作業手順

Bot用のスクリプトを書く

まず、Twitter Botがアカウントにアクセスするためのトークンを取得しておきます。

  • Twitter Application Managementのページを開き適当なTwitterアカウントでログインする。
  • Create New Appをクリックし、アプリ名など必要事項を記入、Developer Agreementに同意し、Create your Twitter applicationをクリックする。
  • 作成したアプリのステータスページが現れるので、Keys and Access Tokensのタブを開く。
  • Consumer Key (API Key)Consumer Secret (API Secret)をメモしておく。
  • ページ下部のCreate my access tokenをクリックし、Access TokenAccess Token Secretを発行する。Accsess LevelRead and write以上になっていることを確認しておく。
  • 以上4つのKeyを取得したら、twitterTokens.pyにディクショナリー形式でスクリプト本体とは別ファイルとして保存する。スクリプトに直接書くとセキュリティがおろそかになることや、取り回しが不便になるためである。
tokens = dict(
    consumer_key =          '******************',
    consumer_secret =       '******************',
    access_token =          '******************',
    access_token_secret =   '******************',
)

スクリプトmocotwi.pyの全容は以下のようになりました。

import requests
import bs4
import datetime
import re
# Load twitter tokens from the external twitterTokens.py file.
# Tokens are in the dictionary named "tokens".
from twitterTokens import tokens
import tweepy

url = 'http://www.ntv.co.jp/zip/mokomichi/index.html'
res = requests.get(url)
moco = bs4.BeautifulSoup(res.text.encode(res.encoding), "html.parser")


# Are there any updates?
def isUpdate():
    utcDelta = 9  # JST timezone
    now = datetime.datetime.utcnow() + datetime.timedelta(hours=utcDelta)
    date = moco.select('.recently time')
    return date[0].attrs['datetime'] == now.strftime('%Y-%m-%d')


def getMenu():
    menuHTML = moco.select('.recently h3')
    menu = menuHTML[0].getText().strip()
    # remove prefix if exists
    remove = re.compile('もこみち流[\s ]+')
    if remove.match(menu):
        menu = remove.sub('', menu)
    return menu


def tweetMenu():
    # set tokens
    CONSUMER_KEY = tokens['consumer_key']
    CONSUMER_SECRET = tokens['consumer_secret']
    ACCESS_TOKEN = tokens['access_token']
    ACCESS_TOKEN_SECRET = tokens['access_token_secret']
    # auth process
    auth = tweepy.OAuthHandler(CONSUMER_KEY, CONSUMER_SECRET)
    auth.set_access_token(ACCESS_TOKEN, ACCESS_TOKEN_SECRET)
    api = tweepy.API(auth)
    # send tweet
    api.update_status(getMenu() + ' #mocos_kitchen ')


def lambda_handler(event, context):
    if isUpdate():
        tweetMenu()


if __name__ == '__main__':
    lambda_handler(None, None)

上から順番に補足します。

from twitterTokens import tokens

各モジュールをインポートする際に先ほどのトークンを記述したファイルも読み込みます。

moco = bs4.BeautifulSoup(res.text.encode(res.encoding), "html.parser")

requestsで取得したサイトをBeautifulSoupに渡す時に、文字コードが正しくエンコードされていないと後の処理がうまくいきませんでした。ref: pythonとBeautifulsoupとrequests - adragoonaの日記

def isUpdate():
    utcDelta = 9  # JST timezone
    now = datetime.datetime.utcnow() + datetime.timedelta(hours=utcDelta)
    date = moco.select('.recently time')
    return date[0].attrs['datetime'] == now.strftime('%Y-%m-%d')

MOCO’Sキッチンのサイトが更新されているか確認します。AWS Lambdaでdatetime.datetime.now()するとどうやらUTCで動いているようなので、JSTになるよう9時間分進めています。サイト内の日付がスクリプト実行時の日付と同じであればTrueを返します。

def getMenu():
    menuHTML = moco.select('.recently h3')
    menu = menuHTML[0].getText().strip()
    # remove prefix if exists
    remove = re.compile('もこみち流[\s ]+')
    if remove.match(menu):
        menu = remove.sub('', menu)
    return menu

料理名の箇所を抜き出します。HTML要素からgetText()すると改行の\nが付いてきたのでstrip()で削っています。また、頭の「もこみち流 (全角スペース)」の部分を取り除くため続けて削っています。(16/11/07 追記) 全角スペースだけでなく半角が混ざることもあるので正規表現で対応します。さらに、特別企画の際には「もこみち流」が付かない場合もあるので、その表記が含まれているのかどうかも確認します。

そしてtweetMenu()でトークンを読み込み認証を行い、抽出した料理名にハッシュタグを付けてつぶやかせます。ref: Authentication Tutorial — tweepy 3.5.0 documentation

def lambda_handler(event, context):
    if isUpdate():
        tweetMenu()

mainとなる関数部分にはlambda_handler(event, context)というAWS Lambdaが識別できるような関数名をつけます。名称自体は任意に設定可能ですがここでは分かりやすくこのような名前にしました。ref: Lambda 関数ハンドラー (Python) - AWS Lambda

AWS Lambdaを設定する

スクリプトが用意できたのでAWS Lambdaにアップロードします。Lambdaドキュメントにしたがってzipファイルを作ります。ref: デプロイパッケージの作成 (Python) - AWS Lambda

まず作業用ディレクトリを作り、そこに必要なファイルを置きます。

$ mkdir mocotwi
$ cd mocotwi
$ tree .
.
├── mocotwi.py
└── twitterTokens.py

サードパーティー製のPythonモジュールはAWS Lambdaには用意されていないので、zipファイル内に同梱しなければなりません。pipを使って必要なモジュールをカレントディレクトリ内にインストールします。ドキュメントにも記載されていますがMacでHomebrewから導入したPythonを使っている場合、正常にインストールされないので次のファイルを用意しておきます。

$ cat setup.cfg
[install]
prefix=
// 上のファイルがあるとHomebrew環境下でも正常にインストールされる
$ pip3 install -t . requests beautifulsoup4 tweepy 
// -tオプションでカレントディレクトリ内にインストールするよう指定

必要なものが揃ったのでzipファイルを生成します。

$ zip -r ~/mocotwi.zip *

ホームディレクトリにmonotwi.zipができました。

ここからAWS Lambdaのコンソール上での作業です。

  • Lambda Management Consoleにログインし、Get Started Now(2回目以降はCreate a Lambda function)をクリック。
  • テンプレート一覧が表示される。Blank Functionを選択する。Configure triggersの画面が表示されるが次項で設定するので、ここではNextをクリックし次へ進む。
  • Configure function内の項目を埋める。Nameには作成するLambda functionの名前(mocotwiなど)、Descriptionにはその説明を入力、RuntimeではプルダウンからPython 3.6を選択。
  • Lambda function codeCode entry typeではUpload a .ZIP fileを選択する。Uploadボタンをクリックし、先ほどのmocotwi.zipを指定する。
  • Lambda function handler and roleHandlerにはmocotwi.lambda_handlerと入力。トリガーとなるmain関数を呼び出し、ファイル名.main関数名の形式をとる。Roleにはプルダウンから適当なロールを選択するが、初めて利用する場合は存在しない。Create new roleBasic execution roleを選択すれば必要最小限の権限を持ったロールを作成してくれる。
  • Advanced settingsは特に変更せずNextをクリック。
  • 最終確認の画面が表示されるので確認してCreate functionをクリック。

最後にcronの設定を行います。

  • 先のCreate function後Lambda functionのステータス画面に遷移するのでTriggers のタブをクリック。
  • Add triggerをクリック、さらに空欄になったアイコンをクリックし、プルダウンからCloudWatch Events - Scheduleを選択。
  • Rule nameRule descriptionに名前と説明を入力し、Schedule expressionの項目ではプルダウンメニューからcronを選択する。
  • 選択したcronは書き換えることが可能で、内容をcron(2 23 ? * SUN-THU *)に変更する。MOCO’Sキッチン放送後の平日朝8:02(JST)に実行するようにしたいので9時間分巻き戻して、日曜から木曜の23:02(UTC)と設定した。cronの書き方は以下を参照。ref: スケジュールされたイベントでの AWS Lambda の使用 - AWS Lambda
  • Enable triggerにチェックが入っていることを確認しSubmitをクリックする。

以上で全ての作業は終了です。

さいごに

このBotを今週の初めから動かしていますが今のところ順調に動作しています。つぶやき元となっているTwitterクライアントが表示されるTwitterクライアント(紛らわしい)を持っている方は、ここ数日のMOCO’Sキッチンツイートを見るとfrom mocotwiのようになっているのが確認できると思います。

ただページが更新されなかった場合やメニューの書き方が突然変わった場合など、不測の事態に備えたエラーハンドリングを全くとっていないので、そういうケースに遭遇したら手動で確認しなければなりません。なにかうまく対処できればよいのですが対応策が思い浮かびません。

また気になるお値段ですが、AWS Lambdaの料金ページを見ると、

  • リクエストのうち毎月最初の 1,000,000 件は無料
  • 1 GB/秒の使用につき 0.00001667 USD の料金

とあります。このスクリプトは平日に1回呼び出すだけなので月20回くらいとなり、リクエストに関しては余裕で無料の範囲内に収まると思われます。実行時間にかかる料金は、ログを見たところmocotwiスクリプトの実行時間は700ミリ秒でメモリは128MBの環境で行っているので、

合計コンピューティング(秒)= 20 × 0.7 = 14秒
合計コンピューティング(GB/秒)= 14 × 128 MB / 1024 = 1.75 GB/秒
1か月のコンピューティング料金= 1.75 × 0.00001667 USD = 0.0000291725 USD

となり、これお金かかるのかなという試算になります。どこかに見落としがないか不安ですが、具体的な請求が来たらご報告します。また前回書き忘れていましたが、AWS S3 + CloudFrontでのブログのホスト料金も安く抑えられるはずですので合わせてお知らせできればと思います。

追記(2016/07/05)

遅くなりましたが、2月から6月まで5ヶ月分の請求が来たので内訳をお知らせします。表中の単位は$です。また、請求は来ましたが、以前AWSのアンケートに答えて$25分のクーポンを貰っていたので実際に支払った金額はゼロで済みました。定期的にアンケートが行われるので、ひょっとしてずっと貰い続けられるのではと淡く期待しています。

|          |Feb |Mar |Apr |May |Jun |
|----------|----|----|----|----|----|
|S3        |0.05|0.02|0.02|0.03|0.03|
|CloudFront|0.12|0.16|0.13|0.12|0.15|
|Route 53  |0.51|0.51|0.51|0.51|0.51|
|Total     |0.68|0.69|0.66|0.66|0.69|

予想通りAWS Lambdaの料金はかかりませんでした。S3 + CloudFrontを利用したブログのホスト代はだいたい$0.15-0.2くらいでしょうか。Route 53はDNSサービスとして利用しており、1つのドメインにつき1ヶ月$0.5とDNSクエリ代が$0.01かかっていました。信頼性や豊富な機能からAWSのDNSサービスを利用していますが、ドメインを購入する際にレジストラが無料で提供しているところも多いので、それを利用すればさらに費用は抑えられるでしょう。