QR Send

スマホとPCのChrome間で、URLやテキストをQRコード1回のペアリングで送り合える自作Chrome拡張。アカウント不要・無料。

使ってみる →

SeleniumのRubyスクレイパーをGitHub Actionsで定期実行する

Selenium・自動化

ローカルでcronを回し続けるための「常時起動PC」の限界

RubyとSeleniumで書いたスクレイパーを毎日決まった時刻に動かしたいとき、最初に思いつくのは手元のPCやサーバーにcron(Windowsならタスクスケジューラ)を仕込む方法です。動くには動くのですが、しばらく運用していると面倒な点が積み重なってきます。

まず、PCを24時間起動しっぱなしにする必要があります。ノートPCならスリープに入らないよう設定を変えないといけませんし、OSアップデートで再起動がかかれば、その日の実行がまるごと飛びます。さらに、実行環境がそのPC固有のもの(Chromeのバージョン、Rubyのバージョン、gemの状態)に依存するため、PCを買い替えたり別の担当者に引き継いだりするたびに、同じ環境を作り直す手間が発生します。個人開発の小さなバッチであっても、「動かし続けるためのインフラ」を自分で持つコストは意外と馬鹿になりません。

GitHub Actionsのscheduleなら、動いている間だけ課金される

そこで、実行環境そのものをGitHub Actionsに任せてしまう方法があります。on.scheduleにcron式を指定しておけば、GitHub側が指定した時刻にランナー(仮想マシン)を起動し、ワークフローを実行して、終わったらランナーごと破棄してくれます。

自分のPCを起動しておく必要がなく、実行のたびにまっさらなubuntu-latest環境が用意されるので、「前回の実行でゴミが溜まって挙動がおかしい」といった類の問題からも解放されます。パブリックリポジトリなら実行時間は無料、プライベートリポジトリでも無料枠(Freeプランで月2,000分)があるので、1日1回・数分で終わるような巡回バッチであれば、費用を気にせず定期実行できます。

ubuntu-latestにヘッドレスChromeを用意する

ubuntu-latestランナーにはあらかじめGoogle Chromeが入っていますが、バージョンが固定されているとは限らず、ChromeDriverとのバージョン不一致で落ちることがあります。ここではbrowser-actions/setup-chromeアクションを使って、明示的にChromeのバージョンを指定してインストールする方法にします。

Selenium 4.6以降は「Selenium Manager」が組み込まれており、Selenium::WebDriver.for(:chrome, ...)を呼ぶだけで、インストールされているChromeに合ったChromeDriverを自動で解決してくれます。そのため、ワークフロー側でChromeDriverを個別にダウンロードする手順は基本的に不要です。Chrome本体さえ確実にセットアップできていれば、ドライバ側はSelenium任せで動きます。

ワークフローYAMLの全体像

実際のワークフローファイルは次のようになります。scheduleでcron式を指定し、workflow_dispatchも併記しておくと、手動でも「Run workflow」ボタンから即座に実行できて動作確認がしやすくなります。

name: Daily Scrape

on:
  schedule:
    - cron: '0 21 * * *'  # UTC 21:00 = JST 翌6:00
  workflow_dispatch:

jobs:
  scrape:
    runs-on: ubuntu-latest
    timeout-minutes: 15
    steps:
      - uses: actions/checkout@v4

      - uses: ruby/setup-ruby@v1
        with:
          ruby-version: '3.2'
          bundler-cache: true

      - uses: browser-actions/setup-chrome@v1
        id: setup-chrome
        with:
          chrome-version: stable

      - name: Run scraper
        env:
          SITE_LOGIN_ID: ${{ secrets.SITE_LOGIN_ID }}
          SITE_LOGIN_PASSWORD: ${{ secrets.SITE_LOGIN_PASSWORD }}
          CHROME_BIN: ${{ steps.setup-chrome.outputs.chrome-path }}
        run: bundle exec ruby scrape.rb

      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: scrape-result-${{ github.run_id }}
          path: |
            output/result.csv
            output/*.log
          retention-days: 14

timeout-minutesは必ず設定しておきます。Seleniumの待機がどこかで詰まってハングした場合、指定がないとジョブが上限(デフォルト360分)近くまで枠を消費し続け、無料枠を無駄に食いつぶすことになるためです。

Ruby側はこのようなシンプルな構成にしておきます。

require 'selenium-webdriver'
require 'csv'
require 'fileutils'

options = Selenium::WebDriver::Chrome::Options.new
options.binary = ENV['CHROME_BIN'] if ENV['CHROME_BIN']
options.add_argument('--headless=new')
options.add_argument('--no-sandbox')
options.add_argument('--disable-dev-shm-usage')
options.add_argument('--window-size=1280,800')

driver = Selenium::WebDriver.for(:chrome, options: options)

begin
  driver.get('https://example.com/login')

  driver.find_element(:name, 'login_id').send_keys(ENV.fetch('SITE_LOGIN_ID'))
  driver.find_element(:name, 'password').send_keys(ENV.fetch('SITE_LOGIN_PASSWORD'))
  driver.find_element(:css, 'button[type="submit"]').click

  wait = Selenium::WebDriver::Wait.new(timeout: 15)
  wait.until { driver.find_elements(:css, '.item-row').any? }

  FileUtils.mkdir_p('output')
  CSV.open('output/result.csv', 'w') do |csv|
    csv << ['title', 'url']
    driver.find_elements(:css, '.item-row').each do |row|
      title = row.find_element(:css, '.item-title').text
      url = row.find_element(:css, 'a').attribute('href')
      csv << [title, url]
    end
  end
ensure
  driver.quit
end

--no-sandbox--disable-dev-shm-usageは、コンテナ環境のランナーでChromeを安定して起動するためほぼ必須のオプションです。/dev/shmのサイズが小さいCI環境ではこれを付けないとタブがクラッシュしやすくなります。

認証情報はSecretsに、結果はartifactで持ち帰る

ログインIDやパスワードのような認証情報は、リポジトリの「Settings > Secrets and variables > Actions」に登録し、ワークフローからは${{ secrets.SITE_LOGIN_PASSWORD }}のように参照します。コード中に平文で書かないのはもちろん、ログ出力にも認証情報が混ざらないよう、putsでCookieやパスワードをそのまま出さないよう注意が必要です。GitHub側もSecretsの値をログに出そうとすると自動的にマスクしてくれますが、値を加工してから出力するとマスクが効かなくなることがあるため過信は禁物です。

実行結果のCSVやログファイルは、ランナーが終了すると消えてしまうため、actions/upload-artifactでジョブの成果物として保存しておきます。if: always()を付けておくと、途中の処理が失敗した場合でもそこまでのログをartifactとして残せるので、失敗原因の調査がしやすくなります。artifactは既定で90日、retention-daysを指定すればその日数だけ保持されます。

実行時刻とタイムゾーンの注意

scheduleのcron式は必ずUTCで評価されます。日本時間で「毎朝6時に実行したい」場合は、UTCの前日21時(0 21 * * *)を指定する必要があり、うっかりJSTのつもりで書くと9時間ずれた時刻に動いてしまいます。

また、GitHub Actionsのscheduleは「指定した時刻ちょうどに必ず実行される」ことを保証していません。GitHub側の負荷状況によっては、数分から時には数十分単位で実行が遅延することがあります。時刻の厳密さが求められる処理(特定の時刻のデータだけが欲しい、といったケース)には向かないため、ある程度の遅延を許容できる巡回・収集系のバッチに向いた仕組みだと捉えておくとよいでしょう。

まとめ

  • ローカルPCの常時起動によるcron運用は、スリープ・再起動・環境差異といった運用コストが積み重なりやすい
  • GitHub Actionsのschedule(cron)を使えば、実行のたびにクリーンな環境が用意され、無料枠内で定期実行が完結できる
  • browser-actions/setup-chromeでChromeを用意し、ChromeDriverの解決はSelenium ManagerにまかせるとYAMLがシンプルになる
  • timeout-minutesを必ず設定し、ハングしたジョブが無料枠を無駄に消費しないようにする
  • 認証情報はSecretsに、実行結果はactions/upload-artifactでartifactとして持ち帰る
  • cron式はUTC基準であること、実行時刻に数分〜数十分の遅延が起こり得ることを前提に設計する

コメント

タイトルとURLをコピーしました