otsunekoの日常

Twitterのヘッダ画像を自動更新してくれるツールを開発した話

Twitterプロフィールバナー

久しぶりに競プロ関係のツール開発です。

僕は以前から幕田元さんの開発されたWebサービスであるAtCoder Twitter Profile Updater(参考)をありがたく使わせて頂いており、毎週月曜に最新のAtCoderレート画像がTwitterプロフィールのバナーとして登録されるようになっていました。

ただ、作者の幕田元さんのTwitterアカウントが突如削除されたり、AtCoder Twitter Profile UpdaterにTwitterアカウントでログインする際エラーが出たりと、何やら不穏な感じになっていました。まずは幕田元さんに何事も無いことを願っています…

元々無償のサービスを善意でやって頂いてたわけですし、使えなくなること自体は仕方がないので、自分専用にはなるものの類似機能を持つツールを開発するかと思い立って今回Herokuにデプロイしました。ソースのGithubリンクはこちらになります。Pythonで雑に書いてます。Herokuへのデプロイにあたっては以前作った競プロのレート更新LINE通知ツールを参考にしており、今回はDBを使わないため更にシンプルな作りです。

もしも似たようなツールの作成を考えていらっしゃる方がいれば、ご参考になれば幸いです。

アイデア

  • Selenium、Puppeteer等の自動ブラウザ操作ツールを使ってAtCoderとCodeforcesのサイトからレート画像をキャプチャする
  • Pillowを使ってキャプチャした画像のサイズを修正する
  • Tweepyのupdate_profile_banner()を使ってヘッダ画像更新 ※予めTwitter APIの登録が必要(参考)

参考ページ

ローカル開発環境

  • Windows 10 + VSCode
  • Google Chrome バージョン: 94.0.4606.81(Official Build) (64 ビット)
  • chromedriver-binary : 94.0.4606.61.0
  • selenium : 3.141.0

成果物一式

メインのソースコード

TwitterのAPIキー等はHerokuのConfig Varsに環境変数として登録し、os.environで呼び出しました。また、画像のリサイズ部分は本当に適当です。もっと良い書き方があると思いますが、動いたからヨシ!です。

main.py
import os
import io
import time
import tweepy
from PIL import Image
from selenium import webdriver
from selenium.webdriver.chrome.options import Options

# 画像を横並びで連結
def concat_h(img1, img2, color="black"):
    dst = Image.new(
        "RGB", (img1.width + img2.width, max(img1.height, img2.height)), color
    )
    dst.paste(img1, (0, 0))
    dst.paste(img2, (img1.width, 0))

    return dst

# AtCoderとCodeforcesのレート推移画像の取得及びリサイズ
user_name = os.environ['USER_NAME']
ac_url = "https://atcoder.jp/users/" + user_name
cf_url = "https://codeforces.com/profile/" + user_name

options = Options()
options.add_argument('--headless')
options.add_argument('--lang=ja-JP')
driver = webdriver.Chrome(options=options)

# AtCoder
driver.get(ac_url)
driver.set_window_size(1920, 1080)
time.sleep(10)
img_png = driver.get_screenshot_as_png()
img_io = io.BytesIO(img_png)
img_ac = Image.open(img_io)
x,y = 700,310
width,height = 630,445
img_ac = img_ac.crop((x, y, x+width, y+height))
img_ac = img_ac.resize((int(img_ac.width * 0.9), int(img_ac.height * 0.9)))

# Codeforces
driver.get(cf_url)
driver.set_window_size(1920, 1080)
time.sleep(10)
img_png = driver.get_screenshot_as_png()
img_io = io.BytesIO(img_png)
img_cf = Image.open(img_io)
x,y = 370,580
width,height = 880,345
img_cf = img_cf.crop((x, y, x+width, y+height))
img_cf = img_cf.resize((700,400))

# Twitterのプロフィールヘッダ用にAtCoderとCodeforcesのレート推移画像の連結及びリサイズ
img_concat = concat_h(img_cf, img_ac)
img_concat = img_concat.resize((int(img_concat.width * 0.95), img_concat.height))

driver.quit()

# Twitter APIを使ってプロフィールヘッダ画像をレート推移画像に変更
API_KEY = os.environ['API_KEY']
API_SECRET = os.environ['API_SECRET']
ACCESS_TOKEN = os.environ['ACCESS_TOKEN']
ACCESS_TOKEN_SECRET = os.environ['ACCESS_TOKEN_SECRET']

auth = tweepy.OAuthHandler(API_KEY, API_SECRET)
auth.set_access_token(ACCESS_TOKEN, ACCESS_TOKEN_SECRET)

api = tweepy.API(auth)

# Herokuは一時ファイル保存できなかったのでバイナリデータで更新
img_bytes = io.BytesIO()
img_concat.save(img_bytes, 'png')
api.update_profile_banner("kyopro.png", file=img_bytes.getvalue())

requirements.txt

今回指定したパッケージは以下です。

requirements.txt
Pillow==8.3.2
tweepy==4.1.0
selenium==3.141.0

runtime.txt

runtime.txt
python-3.9.5

procfile

Procfile
web: python main.py

その他Heroku設定

本ツールの定期実行にはおなじみHeroku SchedulerのAdd-onを使用しました(参考)。 実行周期はこどふぉが不定期開催なこともあり、エブリデイにしています。というか週1回の更新にしようとしたら最長周期が1日でした。

Heroku Scheduler

また、Heroku上でChromeブラウザを使ったSeleniumの自動ブラウザ操作を実現するために「chromedriver」と「google-chrome」のBuildpackが必要になりますので、その設定もしました(参考)。

最終的にbuildpackは以下のような感じになりました。

buildpack

今回のハマりポイント

ローカルテスト時はスムーズにいきましたが、Herokuへのデプロイ周りで環境起因の問題に遭遇しました。

ModuleNotFoundError: No module named tweepy

最初にデプロイした際、Requirements.txtにtweepyを記載しておりビルドも成功しているにも関わらずModuleNotFoundError: No module named tweepyのランタイムエラーが出ました。

直接の原因は分かっていないのですが、暫定解決策として一旦Buildpackを全て削除してビルドした後に再度「chromedriver」と「google-chrome」を登録し直すというのでエラーは取れました。

もしかすると、最初僕が”heroku/python”のbuildpackを追加せずに「chromedriver」と「google-chrome」のみ追加した状態でビルドしていたのが原因かも知れません。

Herokuサーバに画像ファイルを一時保存できない

ローカルテストの際には以下のようなイメージで一度pngファイルとしてローカルに保存した画像を再度読み込んでTwitterにアップロードするという処理になっており、Heroku上でも同じことをしようと考えました。

main_local.py
img_concat.save('kyopro.png')

#(中略)

api = tweepy.API(auth)
api.update_profile_banner('kyopro.png')

Googleで参考記事を漁っていると、Herokuサーバ上でも/tmp/配下にファイルを一時保存可能とする記事があったので試していたのですが結局うまくいかず、こちらの質問回答こちらの記事を参考にしてファイル入出力無しの処理に変更しました。

main.py
# Herokuは一時ファイル保存できなかったのでバイナリデータで更新
img_bytes = io.BytesIO()
img_concat.save(img_bytes, 'png')
api.update_profile_banner("kyopro.png", file=img_bytes.getvalue())

Seleniumの画面キャプチャ領域がローカルとHerokuで異なる

Webページからキャプチャする画像領域はdriver.set_window_size(1920, 1080)のように設定しているのですが、ローカルで実行した場合とHerokuにデプロイして実行した場合とで範囲が異なるのか、その後の切り抜き、リサイズ処理の結果がローカルとHeroku環境で異なりました。

Selenium関連の環境差異によるものだとは思うのですが、思考停止して切り抜きサイズをローカル版とデプロイ版で異なる値にすることで無理やり解決しました。

Heroku上だとSeleniumの言語設定が英語になる

AtCoderのレート推移画像に表示されるコンテスト名は、ブラウザの言語設定により日本語と英語が切り替わるのですが、Herokuにデプロイするとこれがデフォルトで英語表記になっていて、しかも一部の文字が文字化けしていました。ローカルテストだと日本語のコンテスト名が表示されていたので環境起因の問題だと思われます。

まず文字化けに関してはどうやらHerokuに日本語フォントの追加が必要だったようで、こちらの記事を参考に”.fonts”フォルダとNoto Sans Japaneseフォントを追加してGitHubにpushしました。これで文字化けは消えたように思えます。

ただ、依然としてコンテスト名が日本語表記にならず、こちらのサイトを参考にwebdriverのオプションとしてoptions.add_argument(‘—lang=ja-JP’)を設定したのですが、直らず…

うまく画面キャプチャが取れていない

以下画像のように、レート推移画像がうまくキャプチャできていないケースがたまにあったので、time.sleep()の時間を10秒に伸ばしました。

バグ

本当はこちらの記事あたりを参考にして、例えばAtCoderならプロフィールページのソース中からid=“ratingGraph”の要素がロードされるまで待機、といった処理にした方がよい気がしております。

ひとまず様子見して、問題が再発しそうなら修正を検討しようかと…(日和見主義)

(2022/02/11 追記) キャプチャ失敗が無視できない頻度で発生しているのを受け、直接の解決策ではないものの画面キャプチャに失敗(=画像が真っ白)したらTwitterのプロフィールヘッダ画像を更新しないという対策を入れました。判定にはnumpyのarray_equal()メソッドを使用しました。

この判定方法を利用して、キャプチャ失敗したら数回リトライするという処理に変えてもいいかもしれませんね。

GitHubのデフォルトブランチがmain

またしてもハマりました。リポジトリを作ると、デフォルトブランチはmainになっているのに”git push origin main”してもエラーを吐かれていちいちmasterにpushしたものをmainにmergeする羽目になっていたので、こちらのページを参考にデフォルトブランチをmainに変更しました。

(2022/02/11 追記) ヘッダ画像をAtCoderのAlgoとHeuristicに変更

AtCoderでヒューリスティックコンテストの公式レートが実装されたのを受け、ヘッダ画像を変えました。

新ヘッダ画像

まとめ

ローカルで動かすところまでは数時間でいけたものの、Herokuへのデプロイで手こずりました。うまくすればHeroku上のSeleniumの挙動もローカルと揃えられるのかもしれませんね。

あと、このツールもWebサービスとして不特定多数の方に提供できればいいんですが、やってみたい気持ちはありつつも自分がズボラかつ三日坊主な人間なのでメンテし続けられる自信が全くないんですよね…

世の中の便利なサービスたちに圧倒的感謝です。

以上です。