はじめに
何を作ったか
現在未経験からエンジニアに転職するためポートフォリオを作成しています。
本記事では、青空文庫が公開している全作品データ(約18,000件)のCSVファイルをRailsアプリケーションのデータベースに取り込むRakeタスクを実装しました。
実装の過程で以下の課題に直面し、解決しました
- 初回実装では5〜10分かかっていた処理を約30秒に高速化
 - CSVファイルのBOM(Byte Order Mark)問題
 - 何度実行しても安全なべき等性の確保
 
この記事で学べること
- Rakeタスクの基本的な作成方法
 - バルクINSERTによる大量データの高速処理(30〜60倍の改善)
 - CSVファイルのBOM問題とその対処法
 - べき等なデータインポートの実装方法
 - 実際の開発で遭遇した問題のデバッグ手法
 
対象読者
- Railsで大量データのインポート処理を実装したい方
 - Rakeタスクのな使い方を知りたい初心者
 - CSVインポートでパフォーマンス問題に直面している方
 
環境
- Ruby 3.2.2
 - Rails 7.2.2
 - MySQL 8.0.40
 - Docker
 
やりたいこと
- 青空文庫の全作品データ(約18,000件)をDBに格納
 - Rakeタスクで自動化
 - 何度実行しても安全(べき等性)
 
実装の流れ
1. テーブル設計
📌 CSVファイルの取得先
青空文庫の全作品リストCSVは以下に公開されています
新しい作品が公開されるごとに、こちらも更新されるようです
https://www.aozora.gr.jp/index_pages/person_all.html
青空文庫の全作品リストCSVには、作品情報・著者情報・底本情報など約60項目が含まれています。
今回は以下の6項目を抽出してDBに保存します
データ例:「ウェストミンスター寺院」
| DB項目 | CSV項目 | 値 | 
|---|---|---|
| aozora_book_id | 作品ID | 059898 | 
| title | 作品名 | ウェストミンスター寺院 | 
| author | 姓・名 | ワシントン・アーヴィング | 
| published_date | 公開日 | 2020-04-03 | 
| aozora_content_url | テキストファイルURL | https://www.aozora.gr.jp/cards/001257/files/59898_70731.html | 
| aozora_card_url | 図書カードURL | https://www.aozora.gr.jp/cards/001257/card59898.html | 
マイグレーションファイル
class CreateAozoraBooks < ActiveRecord::Migration[7.2]
  def change
    create_table :aozora_books do |t|
      t.string :aozora_book_id, null: false
      t.string :title, null: false
      t.string :author, null: false
      t.string :aozora_content_url, null: false
      t.string :aozora_card_url
      t.date :published_date
      t.timestamps
    end
    add_index :aozora_books, :aozora_book_id, unique: true
    add_index :aozora_books, :title
    add_index :aozora_books, :author
  end
end
2. rakeファイル実装
rails/lib/tasks/aozora_books.rakeを作成し、コードを記述します
Rakeタスクはコマンドで必要なときだけ実行します
コマンド$ rails aozora_books:importで実行できます
インポート処理以外の大枠
require 'open-uri'
require 'zip'
require 'csv'
namespace :aozora_books do
  desc "青空文庫の全作品データ(約18,000件)をCSVからインポート"
  task import: :environment do
    url = "https://www.aozora.gr.jp/index_pages/list_person_all_extended_utf8.zip"
    
    puts "青空文庫CSVをダウンロード中..."
    
    begin
      # ZIPファイルをダウンロード
      URI.open(url) do |zip_file|
        Zip::File.open(zip_file) do |zip|
          # zip.glob('*.csv') = ZIP内の全CSVファイルを検索 / .first = 最初のCSVファイルを取得
          csv_entry = zip.glob('*.csv').first
          
          puts "CSVファイルを読み込み中..."
          csv_data = csv_entry.get_input_stream.read.force_encoding('UTF-8')
          
          # ここにインポート処理(後述)
        end
      end
    rescue => e
      puts "❌ エラー: #{e.message}"
      raise e
    end
  end
end
コードの解説
| 行 | 説明 | 
|---|---|
require 'zip' | 
rubyzip gem(Gemfileに追加必要) | 
task import: :environment | 
Railsの環境を読み込む(モデルが使える) | 
URI.open(url) | 
URLから直接ダウンロード | 
Zip::File.open | 
ZIPを解凍 | 
csv_entry.get_input_stream.read | 
ZIP内のCSVファイルを読めるように開き、中身を全部読み込む | 
インポート処理内部
  ActiveRecord::Base.transaction do
    puts "既存データを削除中..."
    AozoraBook.delete_all
    
    books_data = []
    count = 0
    skipped_count = 0
    line_number = 0
    # CSV.parse が自動的にループする / CSVファイルに含まれる約18,000作品全てに対し実行される
    # 「row」内に1つ1つの作品データが格納される     
    CSV.parse(csv_data, headers: true) do |row|
      line_number += 1
      
      begin
        # 作品IDがない作品はスキップ
        if row['作品ID'].blank?
          puts "⚠️ 行#{line_number}: 作品IDが空です"
          puts "   行の内容: #{row.to_h.inspect}"
          skipped_count += 1
          next
        end
        
        # 本文URLがない作品はスキップ
        if row['XHTML/HTMLファイルURL'].blank?
          skipped_count += 1
          next
        end
        # データを配列に追加(DBには保存しない)
        # データのマッピング:CSVの列名とDBのカラム名を対応付けている
        books_data << {
          aozora_book_id: row['作品ID'],
          title: row['作品名'] || '',  # nil対策
          author: "#{row['姓'] || ''} #{row['名'] || ''}".strip,
          aozora_content_url: row['XHTML/HTMLファイルURL'],
          aozora_card_url: row['図書カードURL'],
          published_date: row['公開日'].presence,
          created_at: Time.current,
          updated_at: Time.current
        }
        count += 1
        # 配列books_dataに1000件追加された時DBにINSERTする
        if books_data.size >= 1000
          # insert_all: 一括挿入(MySQLで動作)
          AozoraBook.insert_all(books_data)
          puts "#{count}件処理完了"
          books_data = []  # 配列をクリア
        end
        
      rescue StandardError => e
        skipped_count += 1
        puts "⚠️ 行#{line_number}でエラー:"
        puts "   作品ID: #{row['作品ID'].inspect}"
        puts "   エラー: #{e.class}: #{e.message}"
        puts "   行の内容(最初の200文字): #{row.to_h.inspect[0..200]}"
        next
      end
    end
    # 残りの1000件以下のデータをINSERT
    if books_data.any?
      AozoraBook.insert_all(books_data)
    end
    # ターミナルに進行状況を表示
    puts "━━━━━━━━━━━━━━━━━━━━━━━━━━"
    puts "✅ インポート完了!"
    puts "   処理件数: #{count}件"
    puts "   スキップ: #{skipped_count}件"
    puts "   DB登録: #{AozoraBook.count}件"
    puts "━━━━━━━━━━━━━━━━━━━━━━━━━━"
  end  # ← トランザクション終了
コードの解説
- 
途中でエラーが発生し中断された場合、その途中までのデータのみMySQLに保存されるのを防ぐためにActiveRecord::Base.transaction do ~ endを使用します
 - 
AozoraBook.delete_allをDB操作の最初に行うことで、べき等性を確保しています
 - 
18,000件を1件ずつINSERTすると非常に遅い(5分~10分)ため、1000件ごとに配列[books_data]へ格納した後、まとめてINSERTします(約30秒)
 - 
残りの作品リストデータが1000件以下になった場合、insert_allが発火しないために個別に記述します
 
3. 躓いたエラー:BOM問題
「2. 実装」で作成したRakeタスクを実行
$ docker-compose exec rails rake aozora_books:import
順調に進んでいると思ったらエラー発生...
CSVファイルを読み込み中...
既存データを削除中...
警告: スキップしました - 作品ID: , エラー: Column 'aozora_book_id' cannot be null
警告: スキップしました - 作品ID: , エラー: Column 'aozora_book_id' cannot be null
警告: スキップしました - 作品ID: , エラー: Column 'aozora_book_id' cannot be null
...
(延々と続く)
全件がスキップされてしまいました
エラーメッセージを見ると
作品ID:の後が空
aozora_book_idがnullになっている
つまりrow['作品ID']がnullを返しているようです。
しかし、CSVファイルを目視で確認すると確実に作品IDデータは入っています
作品ID,作品名,作品名読み,...
"059898","ウェストミンスター寺院","ウェストミンスターじいん",...
"056078","駅伝馬車","えきでんばしゃ",...
エラーの原因
このエラーの原因はCSVファイルの先頭(最初のヘッダー名の前)にBOMが含まれていたのが原因です。
これがCSVファイルの先頭に配置されたため、データ取得時に問題が発生してしまいました
BOMとはByte Order Markの略で、Unicodeテキストファイルの先頭に挿入される特殊なバイトのシーケンスです。
主に、ファイルのエンコーディングと文字の並び順(バイトオーダー)を示すために使用されます。
UTF-8形式のファイルにBOMが含まれると、ファイルを開くときにテキストエディターなどのソフトウェアが正しくエンコードを解釈できるようになります。
エラーへの対応
DB操作前に、BOM削除の処理を追加することで解決しました
  puts "CSVファイルを読み込み中..."
  csv_data = csv_entry.get_input_stream.read.force_encoding('UTF-8')
  csv_data = csv_data.sub("\uFEFF", '')  # ← BOM削除を追加!
  ActiveRecord::Base.transaction do
エラー対処も含めた完全版コード
require 'open-uri'
require 'zip'
require 'csv'
namespace :aozora_books do
  desc "青空文庫の全作品データ(約18,000件)をCSVからインポート"
  task import: :environment do
    url = "https://www.aozora.gr.jp/index_pages/list_person_all_extended_utf8.zip"
    
    puts "青空文庫CSVをダウンロード中..."
    
    begin
      URI.open(url) do |zip_file|
        Zip::File.open(zip_file) do |zip|
          # zip.glob('*.csv') = ZIP内の全CSVファイルを検索 / .first = 最初のCSVファイルを取得
          csv_entry = zip.glob('*.csv').first
          
          puts "CSVファイルを読み込み中..."
          # ZIP内のCSVファイルを読めるように開き、中身を全部読み込む
          csv_data = csv_entry.get_input_stream.read.force_encoding('UTF-8')
          csv_data = csv_data.sub("\uFEFF", '')  # ← BOM削除を追加!
          # トランザクション開始(DB操作のみをトランザクション内で実行)
          ActiveRecord::Base.transaction do
            # すでに一度DBに作品リストが登録済みの場合、エラーになるため実行前に全削除
            puts "既存データを削除中..."
            AozoraBook.delete_all
            
            books_data = []
            count = 0
            skipped_count = 0
            line_number = 0
            # CSV.parse が自動的にループする / CSVファイルに含まれる18,000作品全てに対し実行される
            # 「row」内に1つ1つの作品データが格納されている     
            CSV.parse(csv_data, headers: true) do |row|
              line_number += 1
              
              begin
                # 作品IDまたは本文URLがない作品はスキップ
                if row['作品ID'].blank?
                  puts "⚠️ 行#{line_number}: 作品IDが空です"
                  puts "   行の内容: #{row.to_h.inspect}"
                  skipped_count += 1
                  next
                end
                
                # 本文URLがない作品はスキップ
                if row['XHTML/HTMLファイルURL'].blank?
                  skipped_count += 1
                  next
                end
                # データを配列に追加(DBには保存しない)
                # データのマッピング:CSVの列名とDBのカラム名を対応付けている
                books_data << {
                  aozora_book_id: row['作品ID'],
                  title: row['作品名'] || '',  # nil対策
                  author: "#{row['姓'] || ''} #{row['名'] || ''}".strip,
                  aozora_content_url: row['XHTML/HTMLファイルURL'],
                  aozora_card_url: row['図書カードURL'],
                  published_date: row['公開日'].presence,
                  created_at: Time.current,
                  updated_at: Time.current
                }
                count += 1
                # 配列books_dataに1000件追加された時DBにINSERTする
                if books_data.size >= 1000
                  # insert_all: 一括挿入(MySQLで動作)
                  AozoraBook.insert_all(books_data)
                  puts "#{count}件処理完了"
                  books_data = []  # 配列をクリア
                end
                
              rescue StandardError => e
                skipped_count += 1
                puts "⚠️ 行#{line_number}でエラー:"
                puts "   作品ID: #{row['作品ID'].inspect}"
                puts "   エラー: #{e.class}: #{e.message}"
                puts "   行の内容(最初の200文字): #{row.to_h.inspect[0..200]}"
                next
              end
            end
            # 残りの1000件以下のデータをINSERT
            if books_data.any?
              AozoraBook.insert_all(books_data)
            end
            
            puts "━━━━━━━━━━━━━━━━━━━━━━━━━━"
            puts "✅ インポート完了!"
            puts "   処理件数: #{count}件"
            puts "   スキップ: #{skipped_count}件"
            puts "   DB登録: #{AozoraBook.count}件"
            puts "━━━━━━━━━━━━━━━━━━━━━━━━━━"
          end  # ← トランザクション終了
        end
      end
      
    rescue => e
      puts "❌ エラーが発生しました: #{e.message}"
      puts "   バックトレース:"
      puts e.backtrace.first(5)
      raise e
    end
  end
end
4. 実行結果
BOM削除の処理を追加した後、Rakeタスクを実行したところ、正常に完了しました。
$ docker-compose exec rails rake aozora_books:import
青空文庫CSVをダウンロード中...
CSVファイルを読み込み中...
既存データを削除中...
1000件処理完了
2000件処理完了
...
17000件処理完了
━━━━━━━━━━━━━━━━━━━━━━━━━━
✅ インポート完了!
   処理件数: 17,660件
   スキップ: 217件
   DB登録: 17,660件
━━━━━━━━━━━━━━━━━━━━━━━━━━
実行時間: 約30秒
データが正しくDBに保存されているか、rails consoleで確認してみます。
Loading development environment (Rails 7.2.2.1)
[1] pry(main)> AozoraBook.count
  AozoraBook Count (4.0ms)  SELECT COUNT(*) FROM aozora_books
=> 17660
[2] pry(main)> AozoraBook.first
  AozoraBook Load (1.5ms)  SELECT aozora_books.* FROM aozora_books ORDER BY aozora_booksid ASC LIMIT 1
=> #<AozoraBook:0x00007f93c830c498
 id: 1,
 aozora_book_id: "059898",
 title: "ウェストミンスター寺院",
 author: "アーヴィング ワシントン",
 aozora_content_url: "https://www.aozora.gr.jp/cards/001257/files/59898_...",
 aozora_card_url: "https://www.aozora.gr.jp/cards/001257/card59898.ht...",
 published_date: "2020-04-03",
 created_at: "2025-10-31 05:17:16.692569000 +0000",
 updated_at: "2025-10-31 05:17:16.692607000 +0000">
[3] pry(main)> AozoraBook.where("author LIKE ?", "%夏目%").count
  AozoraBook Count (8.7ms)  SELECT COUNT(*) FROM aozora_books WHERE (author LIKE '%夏目%')
=> 113
データが正常にインポートされ、検索機能も問題なく動作していることが確認できました。
夏目漱石の作品が113件登録されていることも確認できています
終わりに
青空文庫の約18,000件のデータを30秒でインポートするRakeタスクを実装しました。
実装のポイント
- バルクINSERTで30〜60倍高速化(5〜10分 → 30秒)
 - BOM削除でCSV読み込みエラーを解決
 - トランザクションでデータ整合性を保証
 - べき等性を確保し、何度実行しても安全
 
参考資料
最後まで読んでいただきありがとうございました!
同じような実装で困っている方の参考になれば幸いです。