ダウンロード直後にsleepでファイルを開くと、中身が壊れていることがある
Seleniumでファイルダウンロードを自動化していると、ダウンロードリンクをクリックした直後に、決め打ちで数秒sleepしてから保存先のファイルを開く、という書き方をしがちです。小さいファイルならこれでも大抵は動いてしまいます。ですが対象のファイルサイズが大きくなったり、実行環境のディスクやネットワークが混雑していたりすると、sleepの秒数を超えてダウンロードが続いている状態でファイルを読みに行ってしまいます。
厄介なのは「ファイルが存在しないエラー」ではなく、「ファイルはあるのに中身が中途半端」という状態になることです。CSVならパース中に行の途中で切れて例外が飛びますし、PDFや画像なら壊れたバイナリとして扱われ、後続の処理でよくわからないエラーになります。しかも手元のPCは回線が速くて再現せず、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)
こうしておけば、同じディレクトリに前回実行時のファイルが残っていても誤って拾ってしまうことがありません。downloaded_pathが返ってきたら、それが本当にダウンロードが完了した合図です。タイムアウトした場合は例外を投げるので、ダウンロードが完了していないのに後続処理へ進んでしまう事故も防げます。
ヘッドレス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経由で明示的に許可する必要がある
もっとスマートな検知方法をご存知の方がいたら、ぜひコメントで教えてください。


コメント