qr-send

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

使ってみる →

SeleniumのChromeDriverバージョン不一致を自動修復する(Ruby + Windows)

Selenium・自動化

「session not created」でSeleniumが突然動かなくなる

RubyでSeleniumを使ったスクリプトを運用していると、ある日突然こんなエラーで止まることがあります。

Selenium::WebDriver::Error::SessionNotCreatedError: session not created: This version of ChromeDriver only supports Chrome version 124
Current browser version is 126.0.6478.126 with binary path...

原因ははっきりしていて、Chromeは自動更新でどんどんバージョンが上がっていくのに、ChromeDriverは手元に固定したバイナリのまま取り残されるからです。特にWindowsでタスクスケジューラに登録して定期実行しているようなスクリプトだと、ある朝いきなり失敗し始めて「昨日まで動いてたのに」と頭を抱えることになります。

毎回このエラーを見るたびに、

  1. 今のChromeのバージョンを確認する
  2. Chrome for Testingのサイトで対応するChromeDriverを探す
  3. zipをダウンロードして展開する
  4. 古いchromedriver.exeと差し替える

という手順を手でやるのは、地味に面倒です。しかも定期実行しているバッチだと「気づいた頃には数日分の実行が失敗し続けていた」ということにもなりかねません。そこで、このエラーを検知したら自動でChromeDriverを更新してリトライする仕組みをRubyの中に組み込みました。

まずはエラーメッセージから不一致を検知する

Selenium::WebDriver.forを呼んだときに例外が飛んできたら、そのメッセージにバージョン不一致特有の文言が含まれているかどうかで判定します。

def chromedriver_version_mismatch?(error)
  message = error.message.to_s
  message.include?('This version of ChromeDriver only supports Chrome version') ||
    (message.include?('session not created') && message.include?('Current browser version is'))
end

ポイントは、エラークラスそのものではなく「メッセージ文字列に何が含まれているか」で判定していることです。Seleniumのバージョン不一致はSessionNotCreatedErrorという同じ例外クラスの中に、パーミッションエラーやプロファイルロックエラーなど別原因のケースも紛れ込んでいます。クラスだけで判定すると、それらまで巻き込んで「とりあえず自動更新」を発動させてしまい、無駄なダウンロードや余計な待ち時間が発生します。メッセージの文言まで見ることで、本当にバージョン不一致のときだけ更新処理に入るようにしています。

ドライバ生成処理に「不一致なら自動修復してリトライ」を仕込む

ドライバを起動する関数の中で、この判定関数を使って例外をキャッチし、必要なときだけ自動更新→再試行を行います。

def create_chrome_driver(options)
  begin
    Selenium::WebDriver.for(:chrome, options: options)
  rescue => e
    raise unless chromedriver_version_mismatch?(e)

    warn "ChromeDriverとChromeのバージョン不一致を検出。自動更新します: #{e.message.lines.first}"
    update_chromedriver_for_installed_chrome
    Selenium::WebDriver.for(:chrome, options: options)
  end
end

バージョン不一致以外の例外はraiseでそのまま再送出しているのがポイントです。「何でもリトライすれば直る」という雑な作りにすると、他の種類のエラー(ネットワーク断やプロファイル破損など)まで自動更新のロジックに巻き込まれてしまい、原因の切り分けが逆に難しくなります。自動修復は「これだと分かっている不具合」に対してだけピンポイントで発動させるのが安全です。

Chromeの実バージョンを取得する

自動更新には、まず今インストールされているChromeの正確なバージョンが必要です。Windowsであれば、実行ファイルのプロパティからPowerShell経由で取得できます。

require 'open3'

def installed_chrome_version
  chrome_path = 'C:\Program Files\Google\Chrome\Application\chrome.exe'
  return nil unless File.exist?(chrome_path)

  stdout, _stderr, status = Open3.capture3(
    'powershell.exe', '-NoProfile', '-ExecutionPolicy', 'Bypass',
    '-Command', "(Get-Item '#{chrome_path}').VersionInfo.ProductVersion"
  )
  return nil unless status.success?

  stdout.strip
end

chrome --versionをコマンドとして直接叩く方法もありますが、環境によってはコンソールウィンドウが一瞬表示されたり、出力フォーマットがバージョンによって微妙に揺れたりすることがあります。実行ファイルのファイルバージョン情報をPowerShellで直接読む方が、余計な出力に振り回されずに安定して取得できます。

Chrome for TestingのJSON APIで対応するChromeDriverを探す

ChromeDriverの配布元であるChrome for Testingは、既知の全バージョンとダウンロードURLをJSONで公開しています。取得したChromeのバージョン文字列と完全一致するエントリを探し、見つからなければメジャーバージョンだけで近いものを探すフォールバックを入れておきます。

$jsonUrl = 'https://googlechromelabs.github.io/chrome-for-testing/known-good-versions-with-downloads.json'
$data = Invoke-RestMethod -Uri $jsonUrl
$entry = $data.versions | Where-Object { $_.version -eq $chromeVersion } | Select-Object -First 1
if (-not $entry) {
  $major = ($chromeVersion -split '\.')[0]
  $entry = $data.versions | Where-Object { $_.version -like "$major.*" -and $_.downloads.chromedriver } | Select-Object -Last 1
}
if (-not $entry) { throw "No ChromeDriver entry found for Chrome $chromeVersion" }

完全一致のエントリが常にあるとは限りません。Chromeは細かいパッチバージョンで自動更新されますが、Chrome for Testing側のビルドがそのすべてのパッチバージョンに対して用意されているわけではないためです。メジャーバージョンさえ合っていればChromeDriverはほぼ問題なく動くので、フォールバックとして許容しています。

ダウンロード・展開・差し替え、そしてRuby側での再試行

対応するダウンロードURLが見つかったら、一時フォルダにzipを落として展開し、既存のchromedriver.exeをバックアップしてから差し替えます。

$download = $entry.downloads.chromedriver | Where-Object { $_.platform -eq 'win64' } | Select-Object -First 1
$tempRoot = Join-Path $env:TEMP "chromedriver-$($entry.version)-$(Get-Date -Format yyyyMMddHHmmss)"
New-Item -ItemType Directory -Path $tempRoot | Out-Null
$zipPath = Join-Path $tempRoot 'chromedriver-win64.zip'
Invoke-WebRequest -Uri $download.url -OutFile $zipPath
Expand-Archive -Path $zipPath -DestinationPath $tempRoot

$newDriver = Get-ChildItem -Path $tempRoot -Recurse -Filter chromedriver.exe | Select-Object -First 1
if (Test-Path $driverPath) {
  $backup = $driverPath + '.bak-' + (Get-Date -Format yyyyMMdd-HHmmss)
  Move-Item -LiteralPath $driverPath -Destination $backup
}
Copy-Item -LiteralPath $newDriver.FullName -Destination $driverPath

差し替え前に古いファイルを削除せず、タイムスタンプ付きでリネームして残しているのがちょっとしたコツです。万一ダウンロードしたバイナリに問題があっても、すぐ元に戻せます。

これをRuby側からOpen3で呼び出し、成功したら差し替え後のドライバパスをSeleniumに教えてあげます。

def update_chromedriver_for_installed_chrome
  chrome_version = installed_chrome_version
  raise 'Chromeのバージョンを取得できませんでした' if chrome_version.nil? || chrome_version.empty?

  kill_chromedriver_processes # 古いchromedriver.exeプロセスが残っているとファイルを上書きできないため

  script = build_update_powershell_script(chrome_version)
  _stdout, stderr, status = Open3.capture3(
    'powershell.exe', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', script
  )
  raise "ChromeDriver自動更新に失敗しました: #{stderr.strip}" unless status.success?

  driver_path = File.expand_path('./bin/chromedriver.exe')
  Selenium::WebDriver::Chrome.driver_path = driver_path if File.exist?(driver_path)
  true
end

更新前に必ずkill_chromedriver_processesでプロセスを止めておく点も地味に重要です。Windowsは実行中のexeファイルを上書きできないため、古いドライバプロセスが生きたままだとCopy-Itemが失敗します。

まとめ

  • ChromeDriverのバージョン不一致は、エラーメッセージの文字列判定で検知するのが実用的。例外クラスだけでは他原因のエラーと区別できない
  • 不一致と判定できたときだけ自動更新し、それ以外の例外はそのまま再送出する。何でもリトライする作りは事故のもと
  • Chrome for TestingのJSON APIを使えば、インストール済みChromeの正確なバージョンに対応するChromeDriverをプログラムから特定できる
  • 更新前にプロセスをkillし、更新後は旧ファイルをリネーム退避してから差し替えると、Windows特有のファイルロックや失敗時の切り戻しに強くなる

コメント

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