ExcelをCSVに一括変換するシェルスクリプト: xlsx2csvコマンド活用と外部コマンド起動コストのベンチマーク

xlsx2csvとシェルスクリプトを使い、ディレクトリ内のExcelファイル(.xlsx)を一括でCSVに変換するコマンドを解説。スクリプトの各部分の説明に加え、basename・python3などの外部コマンド呼び出しがどれだけ実行時間に影響するかをベンチマークで検証します。

このシェルスクリプトは、指定したディレクトリ内のすべてのExcelファイル(.xlsx形式)をCSVファイルに変換し、別のディレクトリに保存します。変換にはPythonの xlsx2csv ライブラリを使用します。

事前準備

1. Pythonのインストール

Pythonがシステムにインストールされていることを確認してください。

2. xlsx2csv ライブラリのインストール

xlsx2csv は、ExcelファイルをCSVに変換するためのPythonライブラリです。以下のコマンドでインストールできます。

pip install xlsx2csv

シェルスクリプト

以下の内容を .sh ファイル(例: excel_to_csv.sh)として保存します。

#!/bin/bash

# 変換対象のExcelファイルが存在するディレクトリ
# 例: デスクトップにあるExcelファイルを対象にする場合
input_dir="${HOME}/Desktop"

# 変換後のCSVファイルを保存するディレクトリ
# 例: デスクトップにcsv_outputディレクトリを作成する場合
output_dir="${HOME}/Desktop/csv_output"

# 出力ディレクトリが存在しない場合は作成する
mkdir -p "${output_dir}"

# ExcelファイルをCSVに変換する関数
# 引数: $1 - 入力Excelファイルのフルパス
convert_excel_to_csv() {
    local input_file="$1"
    # 出力ファイル名を生成 (元のファイル名から拡張子を除き、.csvを付与)
    local output_file="${output_dir}/$(basename "${input_file%.*}").csv"

    echo "Converting '${input_file}' to '${output_file}'..."

    # python3 -m xlsx2csv を使って変換を実行
    # -m オプションは、モジュールをスクリプトとして実行することを意味する
    python3 -m xlsx2csv "${input_file}" "${output_file}"

    if [ $? -eq 0 ]; then # 直前のコマンドの終了ステータスが0なら成功
        echo "Conversion successful: '${output_file}'"
    else
        echo "Conversion failed for: '${input_file}'" >&2 # エラーメッセージは標準エラー出力へ
    fi
}

# 指定ディレクトリ内のすべての.xlsxファイルをループ処理
# for ... in ...; do ... done 構文
for excel_file in "${input_dir}"/*.xlsx; do
    # ファイルが存在するかチェック (ファイルがない場合にループが1回実行されるのを防ぐ)
    if [ -f "${excel_file}" ]; then
        convert_excel_to_csv "${excel_file}"
    fi
done

echo "All Excel files processed."

スクリプトの実行方法

  1. 上記のスクリプトを excel_to_csv.sh などの名前で保存します。
  2. スクリプトに実行権限を付与します。
    chmod +x excel_to_csv.sh
    
  3. スクリプトを実行します。
    ./excel_to_csv.sh
    

スクリプトの解説

  • #!/bin/bash: このスクリプトをBashシェルで実行することを指定します。
  • input_dir, output_dir: 変換元と変換先のディレクトリパスを定義します。${HOME} はユーザーのホームディレクトリを表します。
  • mkdir -p "${output_dir}": 変換後のCSVファイルを保存するディレクトリが存在しない場合に作成します。-p オプションは、親ディレクトリも同時に作成し、既に存在してもエラーにしないようにします。
  • convert_excel_to_csv() 関数:
    • local: 関数内で定義された変数がローカル変数であることを示します。
    • basename "${input_file%.*}": ファイル名からパスと拡張子を除去します。
    • python3 -m xlsx2csv "${input_file}" "${output_file}": xlsx2csv モジュールを実行し、ExcelファイルをCSVに変換します。
    • if [ $? -eq 0 ]: 直前のコマンドの終了ステータス $? が0(成功)かどうかをチェックします。
  • for excel_file in "${input_dir}"/*.xlsx; do ... done: input_dir 内のすべての .xlsx 拡張子のファイルをループ処理します。
  • if [ -f "${excel_file}" ]; then ... fi: ループ内で見つかったものが実際にファイルであるかを確認します。これは、*.xlsx にマッチするファイルがない場合にループが1回実行されてしまうのを防ぐためです。

このスクリプトは、Excelファイルを一括でCSVに変換する際に役立ちます。

ベンチマーク:外部コマンド呼び出しはどれくらい重いのか

上記のスクリプトでは、ファイルごとに basename "${input_file%.*}" を呼び出し、さらに python3 -m xlsx2csv という外部プロセスを起動しています。「for ループの中で外部コマンドを1回ずつ呼ぶ」処理は書きやすい反面、ファイル数が増えると無視できないコストになることがあります。実際にどれくらい差が出るのか、3つのパターンで計測しました。

計測環境: macOS (Apple Silicon) / GNU bash 5.3.15。各パターンを50回試行し、平均と標準偏差(stddev)を $EPOCHREALTIME(bashのマイクロ秒精度時刻)で測定しています。

1. ファイル名・拡張子の抽出(200ファイル分をループ処理)

このスクリプトの basename "${input_file%.*}" は、拡張子を除いた部分は ${input_file%.*} というbash組み込みのパラメータ展開で処理していますが、パスからファイル名部分を取り出すのに外部コマンド basename を1ファイルにつき1回forkしています。同じ処理を ${var##*/} という組み込み展開だけで書き換えた場合と比較しました。

# 方式A: 外部コマンド basename を毎回fork(元のスクリプトの書き方)
for f in "${FILES[@]}"; do
  out="$(basename "${f%.*}").csv"
done

# 方式B: bash組み込みのパラメータ展開のみ(forkなし)
for f in "${FILES[@]}"; do
  name="${f##*/}"
  out="${name%.*}.csv"
done
方式平均標準偏差
A: basename を200回fork452.4 ms10.0 ms
B: bash組み込み展開のみ2.1 ms0.06 ms

200回のループで 約215倍 の差が出ました。1回あたりに換算すると basename のfork単体は約2.3ms(452.4ms ÷ 200)で、後述の単発実行の計測値(1.9ms)ともほぼ一致しており、コストの大部分が「プロセスを起動するオーバーヘッド」であることが裏付けられます。

2. プロセス起動コストそのものを比較する

外部コマンド1回あたりのfork+exec自体にどれくらいのコストがかかるのか、python3basename を単発で1回ずつ実行して比較しました。

コマンド平均標準偏差
python3 -c 'pass'(単発)21.7 ms0.32 ms
basename foo.xlsx(単発)1.9 ms0.06 ms

このスクリプトが実際に .xlsx ファイル1つにつき起動する python3 -m xlsx2csv は、Pythonインタプリタの起動だけで約22msかかります。つまり、このスクリプトの実行時間のボトルネックは basename の呼び出し方ではなく、ファイルごとに避けられないPython起動コストにあります。ファイル数が数百・数千規模になる場合は、シェル側の細かい最適化よりも、変換処理自体をPythonスクリプト側でループさせる(Pythonプロセスを1回だけ起動し、その中で全ファイルを処理する)方が効果は大きいでしょう。

3. ループ内の外部コマンド呼び出し vs バッチ実行

一般化した例として、複数ファイルに対して同じ外部コマンドを「1ファイルずつループで呼ぶ」場合と「1回の呼び出しにまとめる」場合も比較しました。200個のログファイルから特定の文字列を含むファイルを探す処理です。

# 方式E: forループの中でgrepを1ファイルずつ呼ぶ(200回fork)
for f in "${LOGFILES[@]}"; do
  if grep -q "failed" "$f"; then
    count=$((count + 1))
  fi
done

# 方式F: 全ファイルをまとめて1回のgrep呼び出しで処理
count=$(grep -l "failed" "${LOGFILES[@]}" | wc -l)
方式平均標準偏差
E: grep をファイルごとに200回fork473.5 ms17.6 ms
F: 全ファイルをまとめて1回の grep26.8 ms4.4 ms

こちらも 約18倍 の差になりました。grepsedawk などはそもそも複数ファイルを引数に取れるように作られているため、「forループで1件ずつ渡す」よりも「配列やワイルドカードでまとめて渡す」方が速く、かつスクリプトの行数も短くなります。

外部コマンドのfork/ループ処理とbash組み込み・バッチ処理の実行時間比較(対数スケール、エラーバーは標準偏差)

まとめ

  • パスからファイル名を取り出すだけなら、外部コマンド basename より bash組み込みのパラメータ展開(${var##*/}${var%.*})の方が 200倍以上 速く、依存コマンドも減らせる。
  • 一方で、今回のスクリプトのように python3 -m xlsx2csv を1ファイルごとに起動する処理は、Pythonインタプリタの起動コスト(約22ms/回)自体が支配的なので、シェル側を組み込み展開に置き換えても全体の実行時間への影響は小さい。ボトルネックがどこにあるかを見極めてから最適化するのが重要。
  • grepsedawk のように複数ファイルをまとめて扱えるコマンドは、forループで1ファイルずつ呼ぶよりバッチで渡した方が大幅に速い(今回の例で約18倍)。ファイル一覧を配列に入れて一括で渡せないか、まず検討する価値がある。

よくある質問(FAQ)

Q. 古い形式の.xlsファイルも変換できますか?

このスクリプトはfor excel_file in "${input_dir}"/*.xlsx; do ... doneというループ条件で対象ファイルを絞り込んでいるため、拡張子が.xlsxのファイルのみが変換対象になる。.xls(Excel 97-2003形式)のファイルはこのグロブにマッチしないため変換されない。.xlsも対象にしたい場合は、ループ条件やファイルの拡張子判定を変更する必要がある。

Q. 変換に失敗したファイルがあると、スクリプト全体が止まりますか?

止まらない。convert_excel_to_csv()関数の中でpython3 -m xlsx2csvの終了ステータスをif [ $? -eq 0 ]でチェックし、失敗した場合は"Conversion failed for: '${input_file}'"というメッセージを標準エラー出力に表示するだけで、forループ自体は次のファイルの処理を継続する。どのファイルが失敗したかは、実行時に出力されるログで確認できる。

Q. 変換対象のファイル数が増えると、なぜ処理が遅くなるのですか?

記事後半のベンチマークで計測した通り、このスクリプトはExcelファイル1つにつきpython3 -m xlsx2csvという外部プロセスを起動しており、Pythonインタプリタの起動だけで1回あたり約22msかかる。ファイル数が数百・数千規模になると、この起動コストの積み重ねが処理時間の大部分を占めるようになる。basename呼び出しをbash組み込み展開に置き換えるような細かい最適化よりも、Python側で全ファイルをまとめてループさせるなど、外部プロセスの起動回数そのものを減らす方が効果が大きい。


関連ツール