ダウンロードボタンを押した直後にファイルを読むと、中身が壊れている
Seleniumでファイルのダウンロードを伴う自動化を書いていると、ダウンロードリンクをクリックした直後に、決め打ちで数秒sleepしてから保存先のファイルを開く、という実装にしてしまいがちです。小さなファイルであればこれでも大抵うまくいきますが、対象のファイルサイズが大きくなったり、実行環境のディスクやネットワークが混雑していたりすると、sleepの秒数を超えてダウンロードが続いている状態でファイルを読みに行ってしまいます。
このとき起きるのは「ファイルが存在しないエラー」ではなく、もっと厄介な「ファイルは存在するが中身が中途半端」という状態です。CSVならパース中に行の途中で切れて例外が飛び、PDFや画像なら壊れたバイナリとして扱われ、後続の処理でよくわからないエラーになります。しかもローカルの手元環境では回線が速くて再現せず、CI環境やクラウド上のバッチでだけ低頻度に発生するため、原因の特定に時間がかかりがちな不具合です。
ダウンロード先をChromeオプションで固定する
まず前提として、ダウンロード先のディレクトリを毎回同じ場所に固定しておきます。固定しておかないと「どこに保存されたファイルを監視すればいいか」自体がわからず、完了検知のしようがありません。
require 'selenium-webdriver'
def build_driver(download_dir)
prefs = {
'download.default_directory' => download_dir,
'download.prompt_for_download' => false,
'download.directory_upgrade' => true,
'safebrowsing.enabled' => true
}
options = Selenium::WebDriver::Chrome::Options.new
prefs.each { |key, value| options.add_preference(key, value) }
options.add_argument('--headless=new')
Selenium::WebDriver.for(:chrome, options: options)
end
download.prompt_for_downloadをfalseにしないと、保存先を尋ねるダイアログが出て自動化がそこで止まってしまいます。safebrowsing.enabledは一部のファイル形式でChromeが警告を出して保存を止めることがあるため、業務で使うバッチとして安定させる目的でtrueにしています。
Chromeは書き込み中のファイルに.crdownloadという拡張子を使う
ダウンロード先を固定したら、次はそこに置かれるファイルの状態を監視します。ここで使えるのがChromeの内部的な挙動です。Chromeはダウンロード中のファイルを、最終的なファイル名に.crdownloadという拡張子を付けた別名で書き込み続け、ダウンロードが完了した瞬間にその拡張子を取り除いて本来のファイル名にリネームします。
つまり、ディレクトリ内に*.crdownloadというファイルが存在する間はまだ書き込みの途中であり、それが消えて最終的なファイル名だけが残った時点で、初めて「ダウンロードが完了した」と判断できます。ポーリングでこの拡張子の有無を見るだけで、外部から完了を検知できるわけです。
def downloading?(download_dir)
Dir.glob(File.join(download_dir, '*.crdownload')).any?
end
.crdownloadが消えるまでポーリングし、念のためサイズ安定も確認する
実際の待機処理では、単に.crdownloadが消えるのを待つだけでなく、ダウンロード対象を「クリック前には存在しなかった新しいファイル」に限定し、さらにファイルサイズが2回連続で同じ値になったことも確認しています。ネットワークドライブなど一部の環境では、リネーム直後の一瞬だけファイルサイズの取得が不安定になることがあるため、この二重チェックを保険として入れています。
def wait_for_download(download_dir, before_files: [], timeout_seconds: 30, poll_interval: 0.5)
deadline = Time.now + timeout_seconds
last_size = -1
while Time.now < deadline
entries = Dir.glob(File.join(download_dir, '*')) - before_files
finished = entries.reject { |path| path.end_with?('.crdownload') }
still_writing = entries.any? { |path| path.end_with?('.crdownload') }
if !still_writing && finished.any?
candidate = finished.max_by { |path| File.mtime(path) }
current_size = File.size(candidate)
return candidate if current_size == last_size && current_size > 0
last_size = current_size
end
sleep poll_interval
end
raise "ダウンロードが#{timeout_seconds}秒以内に完了しませんでした(dir=#{download_dir})"
end
呼び出し側では、クリックする前に既存ファイルの一覧をスナップショットしておき、それとの差分で「今回のクリックで生まれた新しいファイル」だけを対象にします。
before_files = Dir.glob(File.join(download_dir, '*'))
driver.find_element(:css, 'a.download-link').click
downloaded_path = wait_for_download(download_dir, before_files: before_files)
こうしておくと、同じディレクトリに前回実行時のファイルが残っていても誤って拾ってしまうことがなく、タイムアウトした場合は例外を投げて「ダウンロードが完了していないのに後続処理へ進んでしまう」事故を防げます。
ヘッドレスChromeではダウンロードがデフォルトでブロックされる
ここまでの設定はヘッドあり(画面表示あり)のChromeでは問題なく動きますが、CIやサーバー上でよく使うヘッドレスモードでは、セキュリティ上の理由からユーザー操作を介さないダウンロードがデフォルトでブロックされ、上記のオプションを設定していても.crdownloadファイルすら現れないことがあります。
これを許可するには、Chrome DevTools ProtocolのPage.setDownloadBehaviorコマンドを、Seleniumのドライバ経由で明示的に送る必要があります。
def allow_headless_downloads(driver, download_dir)
bridge = driver.instance_variable_get(:@bridge)
path = "session/#{bridge.session_id}/chromium/send_command"
bridge.http.call(:post, path, {
cmd: 'Page.setDownloadBehavior',
params: { behavior: 'allow', downloadPath: download_dir }
})
end
これをドライバ生成の直後、実際にページへアクセスする前に一度呼んでおきます。
def build_driver(download_dir)
# (前述のoptions構築は省略)
driver = Selenium::WebDriver.for(:chrome, options: options)
allow_headless_downloads(driver, download_dir)
driver
end
@bridgeというインスタンス変数を直接触っている点はやや行儀が悪く感じられるかもしれませんが、Rubyのselenium-webdriverには本記事執筆時点でこのコマンドを送るための正式な公開APIが用意されていないため、実務上はこの内部APIを叩く方法が広く使われています。ヘッドレスで動かしているのにダウンロードだけがなぜか動かない、という症状に遭遇したら、まずこの設定漏れを疑うのがおすすめです。
まとめ
- ダウンロード直後に決め打ちの
sleepでファイルを読むと、大きいファイルや混雑した環境でダウンロード未完了のまま壊れた内容を読んでしまう - ダウンロード先はChromeオプションの
download.default_directoryで固定し、監視対象のディレクトリを明確にしておく - Chromeはダウンロード中のファイルに
.crdownload拡張子を使い、完了時にリネームするため、この拡張子の有無で完了を検知できる - クリック前のファイル一覧との差分で対象ファイルを特定し、サイズが2回連続で一致することも確認すると、より確実に完了を判定できる
- ヘッドレスChromeではダウンロードがデフォルトでブロックされるため、
Page.setDownloadBehaviorをCDP経由で明示的に許可する必要がある


コメント