16
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

RUNTEQAdvent Calendar 2021

Day 23

ローカルでスクレイピングを行い、S3を経由してHerokuのDBを定期更新する方法

Last updated at Posted at 2021-12-23

本記事はRUNTEQアドベントカレンダー2021 23日目の記事となります。

はじめに

今回の内容はスクレイピングで外部サイトの情報を取得して、定期的にHerokuのDBを更新する時に必要になるお話です。

Seleniumで動的なサイトをスクレイピングする際に
「ローカルなら動くけど、Herokuだと動かない・・・」

そんな場合の次善策として本記事の手法を活用いただければと思います。

※スクレイピング取得先の利用規約は確認済みで、2秒以上のsleepでサイトに過度な負荷がかからないようにしています。

開発環境

  • ruby 2.6.6
  • Rails 5.2.6
  • gem wheneverselenium-webdriveraws-sdk-s3
  • Herokuデプロイ済み

この手法を採用するに至った背景

やりたかったこと

・縦スクロールしないと全ての要素が表示されないサイト(SNSなど)をSeleniumでスクレイピングする
rake_taskとHeroku Schedulerで、定期的なスクレイピング処理を行い、取得した情報を元にHerokuのDBを更新する

発生したエラー

・以下の設定でHerokuのbuildpackを導入し、Seleniumを実行するもno such elementエラーで要素が取得できない
(ローカルだと取得可能、また、取得要素が少なければHerokuでも取得できるためdriver自体は動いている)

Gemfile
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

エラー文

:WebDriver::Error::NoSuchElementError: no such element: Unable to locate element

エラー原因

driver.page_sourceでソースを表示し、heroku logsでログ確認の上、言語設定、セレクタの指定方法、環境変数、画面サイズなど考えうる要素を変えてテストを繰り返しましたが、原因究明には至らず・・・。

解決策

考えられる解決策としては大きく2パターンありました。

1. Herokuでスクレイピングできるようにエラーを解決

2. スクレイピングはローカルで行う。更新したローカルDBをCSV化して外部ストレージにアップし、Herokuで読み込めるようにする

当初は1のパターンで丸3日ほど試行錯誤していましたが、開発スケジュールを考慮して2のパターンを採用することにしました。

2はアップロード処理など工数が増えてしまいますが、1と比較して以下のメリットがあります。

  • ローカルで柔軟にスクレイピング設定を変更できる
  • 設定変更後にデプロイしなくていいので、PDCAを早く回せる
  • buildpack不要のため150MBほどHerokuのストレージを節約できる

実装内容

手順

**~ ローカル処理 ~**

  1. rake_task(1):スクレイピングとDB更新
  2. rake_task(2):ローカルDBをCSVに書き出し、S3にアップロード
  3. gem wheneverを導入し、rake_task(1),(2)を定期実行
    **~ Heroku処理 ~**
  4. rake_task(3):S3にアップしたCSVを読み込み、HerokuのDBに反映
  5. Heroku Schedulerでrake_task(3)を定期実行

コード

1.ローカルでスクレイピング▼

scraping.rake
  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にアップロード▼

master_csv.rake
  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の設定▼

schedule.rb
# 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にインポート▼

master_csv.rake
  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を定期実行

スクリーンショット 2021-12-23 9.44.20.png

登録方法は以下記事を参照させていただきました。
Herokuでスケジューラ(cron)を設定する方法【Heroku Scheduler】

ローカルでcron実行の注意点

wheneverで毎朝6時にrake_taskを実行する設定を行い、crontab -lで設定に問題がないことを確認しましたが、なぜか動かないという事態に直面しました。

調査したところ、Macbookがスリープ状態の場合、cronが動かない仕様になっていることが原因でした。

Macのシステム環境設定→省エネルギー→スケジュールから、スリープ解除とスリープ開始の時間を設定することで解決できます。

毎朝6時に起きる羽目にならなくてよかったです。

Macデスクトップコンピュータをオン/オフにするスケジュールを設定する

おわりに

以上の手順で、定期的にスクレイピングを行いHerokuのDBを自動更新できるようになります。

やはりHerokuのみでスクレイピング~DB更新まで完了できた方が工数が少なくシンプルですね。

また、そもそもの話でスクレイピングを行わずにサービス運用する方が、取得先に迷惑をかけないですし、安定的な運用ができるという観点もあります。

より良いサービスを提供するために今後も試行錯誤していきたいです。

最後まで読んでいただきありがとうございました!

参考記事

16
6
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
16
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?