1. 結論(この記事で得られること)
この記事を読めば、Rakeタスクで動かすスクレイピング処理の例外処理を、実務レベルで設計・実装できるようになります。
具体的には:
- リトライ可能/不可能なエラーを適切に分類し、無限ループや不要な処理停止を防ぐ設計
- 通知・ログ・状態管理を組み合わせた、障害発生時にも安全に復旧できる実装
- AIを使った例外ハンドリングの設計レビューで、既存コードの危険な箇所を最速で洗い出す方法
- RSpec + VCR を使ったスクレイピングのテスト戦略まで
昔、私も深夜2時に「スクレイピングが500件目で止まってる…」という連絡を受けて、ログもなく原因不明で冷や汗をかいた経験があります。あの頃にこの設計があれば、と思いながら書きました。
2. 前提(環境・読者層)
想定読者
- Rails でスクレイピングやバッチ処理を書いたことがある
- 「rescue => e」 は書けるけど、実務レベルの例外処理設計に自信がない
- 夜間バッチが止まって朝慌てた経験がある
Ruby 3.2+
Rails 7.0+
nokogiri / mechanize / selenium-webdriver (いずれでもOK)
今回扱うユースケース
定期実行される外部サイトのスクレイピングタスクで、以下のような処理を想定します:
- 商品情報を複数ページから取得
- 取得したデータを DB に保存
- 失敗したら Slack 通知
- 途中で止まっても再実行可能
3. Before:よくあるつまずきポイント
よくある NG コード
# lib/tasks/scraping.rake
namespace :scraping do
desc "商品情報を取得"
task fetch_products: :environment do
urls = ProductUrl.where(scraped: false)
urls.each do |url|
begin
html = open(url.value).read
doc = Nokogiri::HTML(html)
Product.create!(
name: doc.css('.product-name').text,
price: doc.css('.price').text.to_i,
url: url.value
)
url.update!(scraped: true)
rescue => e
puts "Error: #{e.message}"
# ここで何もせず次へ…
end
end
puts "完了"
end
end
このコードの問題点(レビューで指摘する箇所)
① すべての例外を一律 rescue している
・ネットワークエラー(リトライすべき)と HTML構造変更(人間の確認が必要)を区別していない
② ログが標準出力のみ
・本番環境で 「puts」 は揮発する。CloudWatch Logs等に残っても検索しづらい
③ 途中失敗時の再開が困難
・どこまで成功したか分からず、全件再実行すると重複する
④ 通知がない
・朝出社するまで気づかない
⑤ タイムアウトの考慮がない
・1件が永遠に待ち続けるとタスク全体が止まる
昔の私もこういうコードをレビューで指摘されて、「たしかに…」と冷や汗をかきました。
4. After:基本的な解決パターン
改善版の全体像
# lib/tasks/scraping.rake
namespace :scraping do
desc "商品情報を取得"
task fetch_products: :environment do
ScrapingTask.new.execute
end
end
# app/services/scraping_task.rb
class ScrapingTask
MAX_RETRIES = 3
RETRY_WAIT = 5 # 秒
TIMEOUT = 30 # 秒
def execute
Rails.logger.info("[ScrapingTask] 開始")
stats = { success: 0, skip: 0, error: 0 }
target_urls.find_each do |url|
result = process_url(url)
stats[result] += 1
rescue => e
handle_fatal_error(e, url)
stats[:error] += 1
end
notify_completion(stats)
Rails.logger.info("[ScrapingTask] 完了: #{stats}")
end
private
def target_urls
ProductUrl.where(scraped: false).where("created_at < ?", 1.hour.ago)
end
def process_url(url)
return :skip if url.scraped?
retries = 0
begin
html = fetch_with_timeout(url.value)
product_data = parse_html(html)
save_product(product_data, url)
:success
rescue RetryableError => e
retries += 1
if retries <= MAX_RETRIES
Rails.logger.warn("[Retry #{retries}/#{MAX_RETRIES}] #{url.value}: #{e.message}")
sleep RETRY_WAIT
retry
else
handle_retryable_error_exhausted(e, url)
:error
end
rescue NonRetryableError => e
handle_non_retryable_error(e, url)
:error
end
end
def fetch_with_timeout(url)
Timeout.timeout(TIMEOUT) do
URI.open(url).read
end
rescue Timeout::Error
raise RetryableError, "タイムアウト: #{url}"
rescue OpenURI::HTTPError => e
if e.io.status.first.to_i >= 500
raise RetryableError, "サーバーエラー: #{e.message}"
else
raise NonRetryableError, "クライアントエラー: #{e.message}"
end
rescue SocketError, Errno::ECONNREFUSED
raise RetryableError, "ネットワークエラー"
end
def parse_html(html)
doc = Nokogiri::HTML(html)
{
name: doc.css('.product-name').text.strip,
price: doc.css('.price').text.gsub(/[^\d]/, '').to_i
}
rescue => e
raise NonRetryableError, "HTML構造が変更された可能性: #{e.message}"
end
def save_product(data, url)
ActiveRecord::Base.transaction do
Product.create!(data.merge(url: url.value))
url.update!(scraped: true, scraped_at: Time.current)
end
end
def handle_retryable_error_exhausted(error, url)
Rails.logger.error("[RetryExhausted] #{url.value}: #{error.message}")
ScrapingFailureLog.create!(
url: url.value,
error_type: 'retry_exhausted',
error_message: error.message,
retryable: true
)
end
def handle_non_retryable_error(error, url)
Rails.logger.error("[NonRetryable] #{url.value}: #{error.message}")
ScrapingFailureLog.create!(
url: url.value,
error_type: error.class.name,
error_message: error.message,
retryable: false
)
notify_slack("🚨 HTML構造変更の可能性\nURL: #{url.value}\nError: #{error.message}")
end
def handle_fatal_error(error, url)
Rails.logger.fatal("[FatalError] #{url.value}: #{error.full_message}")
notify_slack("💥 予期しないエラー\nURL: #{url.value}\nError: #{error.class}: #{error.message}")
end
def notify_completion(stats)
if stats[:error] > 0
notify_slack("⚠️ スクレイピング完了(エラーあり)\n成功: #{stats[:success]}, エラー: #{stats[:error]}")
end
end
def notify_slack(message)
# Slack通知の実装(後述)
end
end
# app/errors/retryable_error.rb
class RetryableError < StandardError; end
# app/errors/non_retryable_error.rb
class NonRetryableError < StandardError; end
改善ポイント
① 例外を3つに分類
・「RetryableError」:一時的な障害(リトライすべき)
・「NonRetryableError」:構造的な問題(人間が確認すべき)
・その他:予期しないエラー(即座に通知)
② ログを構造化
・「Rails.logger」 で出力し、CloudWatch Logs 等で検索可能に
③ 状態管理とべき等性
・「scraped」 フラグで処理済みを判定し、再実行しても安全
④ 通知の使い分け
・一時エラーはログのみ、構造変更は即座に Slack 通知