毎回ログインし直すのはコストが高い
Rubyで書いたSeleniumの自動化スクリプトを定期的に動かしていると、「実行のたびにログインフォームへID・パスワードを入力する」処理がボトルネックになってきます。ログイン処理自体に数秒かかるだけでなく、頻繁にログインを繰り返すとサービス側のセキュリティ判定に引っかかりやすくなり、追加認証を求められることもあります。
一度ログインしてしまえば、あとはブラウザが保持しているCookieをそのまま次回以降のセッションに使い回せれば十分なケースがほとんどです。そこで、ログイン成功後にCookieをファイルへ保存しておき、次回起動時にはログインフォームを経由せずCookie復元だけでログイン済み状態を再現する仕組みを作りました。考え方自体はシンプルですが、実装するといくつか踏み抜きやすい罠があります。
Cookieはそのまま保存・復元できない
SeleniumでCookieを取得するにはdriver.manage.all_cookiesを使いますが、返ってくるCookieオブジェクトにはexpiresやsame_site、httpOnlyといったフィールドまで含まれています。これをそのままJSONに保存して、復元時にadd_cookieへ丸ごと渡すとエラーになります。add_cookieが受け付けるのはname・value・domain・path・secure程度で、それ以外のキーが混ざっているだけで例外を投げるドライバ実装があるためです。
そのため、保存時点で必要なフィールドだけを抽出してクリーニングしておきます。
def clean_cookie_for_save(raw_cookie)
hash = raw_cookie.is_a?(Hash) ? raw_cookie : raw_cookie.to_h
name = hash['name'] || hash[:name]
value = hash['value'] || hash[:value]
return nil unless name && !name.empty? && value
cleaned = { 'name' => name, 'value' => value }
domain = hash['domain'] || hash[:domain]
cleaned['domain'] = domain if domain
path = hash['path'] || hash[:path]
cleaned['path'] = path if path
secure = hash['secure'] || hash[:secure]
cleaned['secure'] = secure if [true, false].include?(secure)
cleaned
end
復元側でも同様に、保存ファイルから読み込んだハッシュのうちadd_cookieが確実に受け付けるキーだけを組み立て直してから渡します。
def build_cookie_for_restore(saved)
cookie = { name: saved['name'], value: saved['value'] }
cookie[:domain] = saved['domain'] if saved['domain'] && !saved['domain'].empty?
cookie[:path] = saved['path'] if saved['path'] && !saved['path'].empty?
cookie[:secure] = true if saved['secure'] == true
cookie
end
expiresやsame_siteを残したくなる気持ちはわかりますが、復元後のセッション維持にはほとんど寄与しない一方、エラーの温床になります。復元に必要な最小限のフィールドだけを渡すのが安全です。
保存はアトミックに書き込む
Cookie保存ファイルを直接上書きしていると、書き込みの途中でプロセスが強制終了した場合に壊れたJSONが残ってしまい、次回起動時にパースエラーで詰まります。定期実行しているバッチだと「気づいたら保存ファイルが壊れていて、以降ずっとログインし直しになっていた」ということが起こり得ます。
これを避けるため、一時ファイルに書き込んでからFile.renameで本来のパスに差し替える、いわゆるアトミック書き込みにしています。
def save_cookies(driver, session_key)
cleaned = driver.manage.all_cookies.map { |c| clean_cookie_for_save(c) }.compact
return false if cleaned.empty?
data = {
'cookies' => cleaned,
'saved_at' => Time.now.to_s,
'session_hash' => session_hash_for(session_key)
}
tmp_path = SESSION_FILE + '.tmp'
File.write(tmp_path, JSON.pretty_generate(data))
File.rename(tmp_path, SESSION_FILE)
true
rescue => e
File.delete(tmp_path) if tmp_path && File.exist?(tmp_path)
warn "Cookie保存に失敗しました: #{e.message}"
false
end
同じファイルパスに対するrenameはファイルシステム上ほぼ瞬間的に完了するため、書き込み途中の中途半端な状態が外から見えることがありません。設定ファイルやキャッシュファイルなど、次回起動時に読み込む前提のファイルは基本的にこの形で保存しておくと安全です。
復元は「対象ドメインを開いてから」行う
Cookieの復元でつまずきやすいのが、ドライバがまだどのページも開いていない(about:blankのような)状態でadd_cookieを呼んでしまうケースです。ブラウザはCookieをドメインに紐づけて管理しているため、対象ドメインのページを一度開いてからでないと、正しいドメインにCookieを関連付けることができずSeleniumがエラーを返します。
そのため復元処理では、必ず先に対象サービスのトップページなどへ一度アクセスしてからadd_cookieを呼ぶ手順を踏みます。
def restore_session(driver, session_key)
return false unless File.exist?(SESSION_FILE)
data = JSON.parse(File.read(SESSION_FILE))
return false unless data['session_hash'] == session_hash_for(session_key)
cookies = data['cookies'] || []
return false if cookies.empty?
driver.get('https://example.com/') # 先にドメインを開いておく
restored = 0
cookies.each do |saved|
begin
driver.manage.add_cookie(build_cookie_for_restore(saved))
restored += 1
rescue Selenium::WebDriver::Error::InvalidCookieDomainError
next
end
end
restored.positive? && important_cookies_present?(driver)
end
ここでInvalidCookieDomainErrorを個別にrescueしているのもポイントです。保存したCookieの中には、サブドメインをまたいで発行されたものなど、現在開いているページのドメインと厳密には一致しないものが混ざることがあります。1件のエラーで処理全体を止めるのではなく、失敗したCookieだけスキップして残りの復元を続けます。
「重要Cookieが1つも無い」をセッション無効の合図にする
add_cookieが例外を投げなかったからといって、そのCookieが実際にログイン状態を再現しているとは限りません。ドメインの微妙な違いや有効期限切れなどで、見た目上は追加できても実質的に無意味なCookieになっていることがあります。
そこで、復元後にブラウザへ実際に反映されたCookie一覧を取得し、そのサービスのログイン状態維持に必須と分かっている特定の名前のCookieが一つも含まれていない場合は、復元自体が失敗したと判定するようにしました。
IMPORTANT_COOKIE_NAMES = ['session_id', 'auth_token'].freeze # サービスごとに実際の名前へ置き換える
def important_cookies_present?(driver)
actual_names = driver.manage.all_cookies.map { |c| c['name'] || c[:name] }
(actual_names & IMPORTANT_COOKIE_NAMES).any?
end
重要なCookie名は、事前に手動ログインしてブラウザの開発者ツールなどで「ログイン状態のときだけ存在するCookie」を特定しておく必要があります。全Cookieのうち1つでもこの名前が含まれていれば復元成功、1つも無ければ「Cookieの数はゼロではないが実質的にセッションは無効」と判断してログイン処理にフォールバックします。件数だけを見て判定すると、期限切れの不要なCookieが大量に残っているだけのケースを成功と誤判定してしまうため、名前で中身を確認するのが確実です。
アカウントとセッションファイルを紐付ける
複数アカウントを扱う自動化では、保存したCookieファイルが「どのアカウントのものか」を区別できないと、別アカウントのCookieを誤って復元してしまう事故につながります。今回はアカウントを特定する文字列(メールアドレスなど)のハッシュ値をセッションファイルに埋め込み、復元時にハッシュが一致するかを必ず確認するようにしています。
require 'digest/md5'
def session_hash_for(session_key)
Digest::MD5.hexdigest(session_key)
end
平文のメールアドレスをそのままファイルに残さず、ハッシュ化した値だけを持たせることで、セッションファイル単体が漏洩した場合の情報量も抑えられます。
まとめ
add_cookieは必須フィールド(name/value/domain/path/secure程度)だけを渡す。expiresやsame_site、httpOnlyを含めるとエラーになることがある- Cookie保存はtmpファイルへ書き込んでから
renameするアトミック書き込みにして、書き込み途中のクラッシュによるファイル破損を防ぐ - Cookie復元は対象ドメインのページを開いた後に行う。ドメイン未確定の状態では正しく関連付けられない
- 復元後、ログイン状態を裏付ける特定Cookie名が1つも見つからなければ「復元失敗」と判定し、通常ログインにフォールバックする
- アカウント識別子のハッシュをセッションファイルに埋め込み、別アカウントのCookieを誤って使い回さないようにする


コメント