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

本記事は[RUNTEQアドベントカレンダー2021](https://qiita.com/advent-calendar/2021/runteq) 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自体は動いている）

```ruby: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
```
エラー文
```Selenium::WebDriver::Error::NoSuchElementError: no such element: Unable to locate element
```

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

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

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

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

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

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

+ ローカルで柔軟にスクレイピング設定を変更できる
+ 設定変更後にデプロイしなくていいので、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](https://devcenter.heroku.com/articles/scheduler)でrake_task(3)を定期実行

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

```ruby: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にアップロード▼

```ruby: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](https://pikawaka.com/rails/carrierwave)
（Rails初学者にとっての神サイトPikawakaさん）

3.wheneverの設定▼

```ruby: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にインポート▼

```ruby: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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2258482/9a5493d8-e018-989c-b8b1-fbc8a549a669.png)

登録方法は以下記事を参照させていただきました。
[Herokuでスケジューラ（cron）を設定する方法【Heroku Scheduler】](https://reasonable-code.com/heroku-cron/)

# ローカルでcron実行の注意点
`whenever`で毎朝6時に`rake_task`を実行する設定を行い、`crontab -l`で設定に問題がないことを確認しましたが、なぜか動かないという事態に直面しました。

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

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

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

[Macデスクトップコンピュータをオン/オフにするスケジュールを設定する](https://support.apple.com/ja-jp/guide/mac-help/mchlp2266/12.0/mac/12.0)

# おわりに

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

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

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

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

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

# 参考記事
+ [【Rails】 CarrierWaveチュートリアル - Pikawaka](https://pikawaka.com/rails/carrierwave)
+ [HerokuでRubyとSeleniumを動かすための設定](https://qiita.com/DanballJP/items/954694abc62eb70d33ed)
+ [Heroku でも Selenium を使いたい!](https://qiita.com/sho7650/items/8fe07126f00c8e94e13b)]
+ [Herokuでスケジューラ（cron）を設定する方法【Heroku Scheduler】](https://reasonable-code.com/heroku-cron/)