0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

RailsのRakeタスクスクレイピングの例外処理改善|AI実務ノート 編集部

0
Last updated at Posted at 2026-01-03

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 通知

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?