本記事はRUNTEQアドベントカレンダー2021 23日目の記事となります。
はじめに
今回の内容はスクレイピングで外部サイトの情報を取得して、定期的にHerokuのDBを更新する時に必要になるお話です。
Seleniumで動的なサイトをスクレイピングする際に
「ローカルなら動くけど、Herokuだと動かない・・・」
そんな場合の次善策として本記事の手法を活用いただければと思います。
※スクレイピング取得先の利用規約は確認済みで、2秒以上のsleep
でサイトに過度な負荷がかからないようにしています。
開発環境
- ruby 2.6.6
- Rails 5.2.6
- gem
whenever
、selenium-webdriver
、aws-sdk-s3
- Herokuデプロイ済み
この手法を採用するに至った背景
やりたかったこと
・縦スクロールしないと全ての要素が表示されないサイト(SNSなど)をSeleniumでスクレイピングする
・rake_task
とHeroku Schedulerで、定期的なスクレイピング処理を行い、取得した情報を元にHerokuのDBを更新する
発生したエラー
・以下の設定でHerokuのbuildpack
を導入し、Seleniumを実行するもno such element
エラーで要素が取得できない
(ローカルだと取得可能、また、取得要素が少なければHerokuでも取得できるためdriver自体は動いている)
gem 'selenium-webdriver'
gem 'chromedriver-helper'
heroku buildpacks:add https://github.com/heroku/heroku-buildpack-google-chrome
heroku buildpacks:add https://github.com/heroku/heroku-buildpack-chromedriver
heroku config:set GOOGLE_CHROME_BIN=/app/.apt/opt/google/chrome/chrome
heroku config:set GOOGLE_CHROME_SHIM=/app/.apt/opt/google/chrome/chrome
エラー文
エラー原因
driver.page_source
でソースを表示し、heroku logs
でログ確認の上、言語設定、セレクタの指定方法、環境変数、画面サイズなど考えうる要素を変えてテストを繰り返しましたが、原因究明には至らず・・・。
解決策
考えられる解決策としては大きく2パターンありました。
1. Herokuでスクレイピングできるようにエラーを解決
2. スクレイピングはローカルで行う。更新したローカルDBをCSV化して外部ストレージにアップし、Herokuで読み込めるようにする
当初は1のパターンで丸3日ほど試行錯誤していましたが、開発スケジュールを考慮して2のパターンを採用することにしました。
2はアップロード処理など工数が増えてしまいますが、1と比較して以下のメリットがあります。
- ローカルで柔軟にスクレイピング設定を変更できる
- 設定変更後にデプロイしなくていいので、PDCAを早く回せる
-
buildpack
不要のため150MBほどHerokuのストレージを節約できる
実装内容
手順
**~ ローカル処理 ~**
- rake_task(1):スクレイピングとDB更新
- rake_task(2):ローカルDBをCSVに書き出し、S3にアップロード
- gem
whenever
を導入し、rake_task(1),(2)を定期実行
**~ Heroku処理 ~** - rake_task(3):S3にアップしたCSVを読み込み、HerokuのDBに反映
- Heroku Schedulerでrake_task(3)を定期実行
コード
1.ローカルでスクレイピング▼
task local_scraping: :environment do
require "selenium-webdriver"
options = Selenium::WebDriver::Chrome::Options.new
options.add_argument('headless')
options.add_argument('disable-gpu')
options.add_argument("--disable-dev-shm-usage")
options.add_argument('--lang=ja-JP')
options.add_argument('--user-agent= 任意のユーザーエージェント')
driver = Selenium::WebDriver.for :chrome, options: options
wait = Selenium::WebDriver::Wait.new(:timeout => 10)
masters = Master.all
#cron.logで実行確認のため時刻を表示
p "#{Time.current}:スクレイピングを開始します"
masters.each do |master|
current_episode = master.episode
sleep 2
driver.navigate.to(master.url)
wait.until { driver.find_elements(:class, '取得対象').size > 0 }
#スクロールして全表示
3.times do
sleep(1)
driver.execute_script('window.scroll(0,1000000);')
end
@titles = []
titles = driver.find_elements(:class, '取得対象')
titles.each do |node|
@titles << node.text
end
#Masterデータの更新
new_episode = @titles.size
if current_episode < new_episode
master.update(episode: new_episode)
end
#cron.logで実行確認のため時刻を表示
p "#{Time.current}:スクレイピングが完了しました"
end
2.ローカルDBをCSV化してS3にアップロード▼
task export: :environment do
#cron.logで実行確認のため時刻を表示
p "#{Time.current}:export処理を開始します"
masters = Master.all
CSV.open("master.csv", "w") do |csv|
column_names = %w(id title media url stream rank created_at updated_at episode)
csv << column_names
masters.each do |master|
column_values = [
master.id,
master.title.to_s,
master.media.to_s,
master.url,
master.stream,
master.rank,
master.created_at,
master.updated_at,
master.episode
]
csv << column_values
end
end
bucket = 'バケット名'.freeze
region = 'ap-northeast-1'.freeze
csv_file = "master.csv"
s3 = Aws::S3::Client.new(
region: region,
access_key_id: ENV['AWS_ACCESS_KEY_ID'],
secret_access_key: ENV['AWS_SECRET_ACCESS_KEY']
)
s3.put_object(bucket: bucket,
key: csv_file,
body: File.open(csv_file, :encoding => "UTF-8"),
content_type: 'text/csv',
)
#cron.logで実行確認のため時刻を表示
p "#{Time.current}:export処理が終了しました"
end
S3の設定については以下記事を参照させていただきました。
【Rails】 CarrierWaveチュートリアル - Pikawaka
(Rails初学者にとっての神サイトPikawakaさん)
3.wheneverの設定▼
# Rails.rootを使用するために必要
require File.expand_path(File.dirname(__FILE__) + "/environment")
# cronを実行する環境変数
rails_env = ENV['RAILS_ENV'] || :development
# cronの環境変数をセット
set :environment, rails_env
# cronのログの吐き出し場所
set :output, "#{Rails.root}/log/cron.log"
# 毎朝6時にスクレイピングを行う
every 1.day, at: '6:00' do
rake "scraping:local_scraping"
end
# スクレイピングで更新後のMasterをCSV化してS3にアップロード
every 1.day, at: '6:15' do
rake "master_csv:export"
end
4.S3にアップしたCSVをHerokuのDBにインポート▼
task import: :environment do
#heroku logsで実行確認のため時刻を表示
p "#{Time.current}:import処理を開始します"
bucket = 'バケット名'.freeze
region = 'ap-northeast-1'.freeze
key = "master.csv"
s3 = Aws::S3::Client.new(
region: region,
access_key_id: ENV['AWS_ACCESS_KEY_ID'],
secret_access_key: ENV['AWS_SECRET_ACCESS_KEY']
)
file = s3.get_object(bucket: bucket, key: key)
lines = CSV.parse(file.body.read)
#eachでDB更新処理を行うためにCSVから読み込んだデータをハッシュ化
keys = lines[0]
data = lines[1...-1].map { |line| Hash[keys.zip(line)] }
lists = []
data.each do |row|
lists << {
id: row["id"],
title: row["title"],
episode: row["episode"]
}
end
#id、episodeをinteger型に変換してDB更新
lists.each do |list|
target = Master.find(list[:id].to_i)
new_episode = list[:episode].to_i
if target.episode < new_episode
target.update!(episode: new_episode)
p "Master: #{list[:title]}を#{list[:episode]}話に更新しました"
end
end
#heroku logsで実行確認のため時刻を表示
p "#{Time.current}:import処理が完了しました"
end
5.Heroku Schedulerで4を定期実行
登録方法は以下記事を参照させていただきました。
Herokuでスケジューラ(cron)を設定する方法【Heroku Scheduler】
ローカルでcron実行の注意点
whenever
で毎朝6時にrake_task
を実行する設定を行い、crontab -l
で設定に問題がないことを確認しましたが、なぜか動かないという事態に直面しました。
調査したところ、Macbookがスリープ状態の場合、cron
が動かない仕様になっていることが原因でした。
Macのシステム環境設定→省エネルギー→スケジュールから、スリープ解除とスリープ開始の時間を設定することで解決できます。
毎朝6時に起きる羽目にならなくてよかったです。
Macデスクトップコンピュータをオン/オフにするスケジュールを設定する
おわりに
以上の手順で、定期的にスクレイピングを行いHerokuのDBを自動更新できるようになります。
やはりHerokuのみでスクレイピング~DB更新まで完了できた方が工数が少なくシンプルですね。
また、そもそもの話でスクレイピングを行わずにサービス運用する方が、取得先に迷惑をかけないですし、安定的な運用ができるという観点もあります。
より良いサービスを提供するために今後も試行錯誤していきたいです。
最後まで読んでいただきありがとうございました!