【変則CSVパース】ダブルクォーテーション内改行・カンマを含む複雑なCSVデータを高精度にパースする自作アルゴリズム
業務自動化の現場において、CSV(Comma-Separated Values)のインポート処理ほど「油断ならない」タスクはない。
「なんだ、`Split(line, “,”)`で一発じゃないか」――そう高をくくったエンジニアが、数日後に地獄を見る光景を私は幾度となく目撃してきた。
現実の業務データ、特に外部システムやユーザがExcelから適当に吐き出したCSVは暴力的だ。
フィールド内にカンマが含まれ、ご丁寧にダブルクォーテーション(`”`)で囲われている。さらに悪質なことに、その引用符の内部に「改行」が混入しているケースすら珍しくない。
単純な行単位の読み込みや、文字列表面だけの分割では、こうした変則データを処理した瞬間にパースが崩壊し、データベースのトランザクション全体を巻き込んで大障害を引き起こす。
今回は、VBScript(WSH)の限られた言語仕様の中で、文字ストリームを1文字ずつ走査し、コンテキスト(状態)を厳密に管理しながら高精度にパースする「ステートマシン(状態機械)型CSVパーサー」の極意を伝授しよう。
—
1. なぜ単純な `Split` では太刀打ちできないのか
多くの初心者が書くコードはこうだ。
‘ 【アンチパターン】絶対に真似してはいけないコード
Do Until ts.AtEndOfStream
line = ts.ReadLine
cols = Split(line, “,”)
‘ カンマや改行がフィールド内にあると、ここでインデックスがズレる!
Loop
このアプローチが破綻する理由は明確である。
CSVの仕様(RFC 4180ベース、あるいはそれに準拠した実務データ)では、フィールド内に区切り文字である「カンマ」や「改行」を含める場合、データ全体をダブルクォーテーションでクローズすることが許容されている。
つまり、文字列としての改行コードと、CSVのレコード区切りとしての改行コードが、構文上同一の `vbCrLf` として表れてしまうため、行単位の処理では「今読んでいる改行はデータの途中なのか、それともレコードの終端なのか」を判別できないのだ。
この問題を解決するには、ファイルを「行単位」ではなく「文字単位(ストリーム)」で捉え、現在の文字がクォートの内側にあるのか外側にあるのかを追跡する仕組みが必要となる。
—
2. 堅牢なパースを実現するステートマシンの設計思想
今回構築するアルゴリズムの核心は 「状態(State)の管理」 である。
パーサーは常に以下の2つの状態のいずれかに身を置く。
1. 通常モード(In Normal State): フィールドの境界や通常の文字を読み込んでいる状態。カンマに出会えばフィールドが区切られ、改行に出会えばレコードが確定する。
2. クォート内モード(In Quoted State): ダブルクォーテーションの内側にいる状態。この中にあるカンマや改行は、たとえ文字であっても「データのただの一部」として強制的に無効化(エスケープ)される。
さらに、クォート文字自体のエスケープ(`””` のようにダブルクォーテーションが連続する場合)も考慮しなければならない。
この複雑な分岐を、VBScriptの `Mid` 関数とループ処理を用いて極限までパフォーマンスを最適化しつつ実装する。
—
3. 【プロダクションコード】完全実装モジュール
以下のコードは、エラーハンドリング、大容量ファイルを想定したストリーム処理、そして複雑な変則CSVの正確な分解をすべて内包した、実戦投入可能なVBScriptモジュールである。
そのまま `.vbs` ファイルとして保存し、テストしてほしい。
‘ ==============================================================================
‘ ファイル名: AdvancedCsvParser.vbs
‘ 概要 : ダブルクォーテーション内の改行・カンマに対応した高精度CSVパーサー
‘ ==============================================================================
Option Explicit
Sub Main()
Dim targetFile
targetFile = “C:\Automation\complex_data.csv”
‘ パーサーの実行
Call ParseComplexCsv(targetFile)
End Sub
Sub ParseComplexCsv(filePath)
Dim fso, ts, fileContent
Set fso = CreateObject(“Scripting.FileSystemObject”)
‘ ファイルが存在しない、あるいは排他制御エラーのハンドリング
If Not fso.FileExists(filePath) Then
WScript.Echo “エラー: 指定されたファイルが見つかりません -> ” & filePath
Exit Sub
End If
‘ 読み取り専用・共有モードでオープン
Set ts = fso.OpenTextFile(filePath, 1, False)
fileContent = ts.ReadAll
ts.Close
Set ts = Nothing
Set fso = Nothing
‘ 1文字ずつ走査して二次元配列(または行・列のコレクション)に分解
Dim records
Set records = ParseCsvString(fileContent)
‘ 実行結果の確認(デバッグ出力)
Dim r, c, rowData
WScript.Echo “パース完了: 総レコード数 = ” & records.Count
For r = 0 To records.Count – 1
rowData = records(r)
‘ 各行のデータをカンマ区切りでコンソール出力(実際にはDBインサートや別処理に繋ぎ込む)
‘ WScript.Echo “Row ” & (r + 1) & “: [” & Join(rowData, ” | “) & “]”
Next
End Sub
‘ ——————————————————————————
‘ 核心アルゴリズム: CSV文字列をステートマシンで解析し、Scripting.Dictionaryの入れ子で返す
‘ ——————————————————————————
Function ParseCsvString(ByRef rawData)
Dim i, length, char, nextChar
Dim inQuotes, currentField, currentRow, resultRows
Set resultRows = CreateObject(“Scripting.Dictionary”)
Set currentRow = CreateObject(“Scripting.Dictionary”)
inQuotes = False
currentField = “”
length = Len(rawData)
i = 1
Do While i <= length
char = Mid(rawData, i, 1)
' 改行コードの正規化処理(CRLFに対応するためCRをスキップしてLFで判定)
If char = vbCr Then
If i < length Then
If Mid(rawData, i + 1, 1) = vbLf Then
' CRLFのCR部分はスキップ(次のLFで改行処理を行う)
i = i + 1
char = vbLf
End If
End If
End If
If inQuotes Then
' --- 【クォート内モード】 ---
If char = """" Then
' 次の文字もダブルクォーテーションかチェック(エスケープされた""の判定)
If i < length And Mid(rawData, i + 1, 1) = """" Then
currentField = currentField & """"
i = i + 1 ' エスケープ分をスキップ
Else
' クォートの終了
inQuotes = False
End If
Else
' クォート内であれば、改行やカンマも含めてすべて純粋な文字として蓄積
currentField = currentField & char
End If
Else
' --- 【通常モード】 ---
If char = """" Then
' クォートの開始
inQuotes = True
ElseIf char = "," Then
' フィールドの区切り -> 現在のフィールドを確定し、行データに追加
Call AddFieldToRow(currentRow, currentField)
currentField = “”
ElseIf char = vbLf Or char = vbCr Then
‘ レコードの区切り -> 現在のフィールドと行を確定
Call AddFieldToRow(currentRow, currentField)
currentField = “”
‘ 行を結果コレクションに格納し、次の行の準備
Call resultRows.Add(resultRows.Count, ConvertDictionaryToArray(currentRow))
Set currentRow = CreateObject(“Scripting.Dictionary”)
Else
‘ 通常文字の蓄積
currentField = currentField & char
End If
End If
i = i + 1
Loop
‘ ループ終了時に残っている最後のフィールド・行の回収(最終行に改行がないケースへの対策)
If Len(currentField) > 0 Or currentRow.Count > 0 Then
Call AddFieldToRow(currentRow, currentField)
Call resultRows.Add(resultRows.Count, ConvertDictionaryToArray(currentRow))
End If
Set ParseCsvString = resultRows
End Function
‘ 補助関数: フィールドデータを現在の行Dictionaryに追加
Sub AddFieldToRow(ByRef rowDict, ByVal fieldValue)
Call rowDict.Add(rowDict.Count, fieldValue)
End Sub
‘ 補助関数: DictionaryをVBScript標準の配列(Array)に変換
Function ConvertDictionaryToArray(ByRef dict)
Dim i, arr()
If dict.Count = 0 Then
ConvertDictionaryToArray = Array()
Exit Function
End If
ReDim arr(dict.Count – 1)
For i = 0 To dict.Count – 1
arr(i) = dict(i)
Next
ConvertDictionaryToArray = arr
End Function
‘ エントリポイントの実行
Call Main()
—
4. プロジェクトアーキテクトとしての実践知見・注意点
このモジュールを実際の業務システムやRPA(Robotic Process Automation)に組み込む際、チーフアーキテクトとして以下のポイントを厳守してほしい。
① メモリ効率と巨大ファイル(Gigabyte級)への対策
今回のアルゴリズムは、`ts.ReadAll` で一度メモリ上にファイルを全展開している。数メガバイト程度のCSVであれば一瞬で処理が終わるため問題ないが、数百MB〜数GBに及ぶ巨大なCSVを処理する場合、この手法はメモリリークやメモリ不足エラー(Out of Memory)の原因になる。
もし巨大ファイルを扱う場合は、`ReadAll` ではなく `Read` メソッドでバッファリングしながらストリーム処理を行うよう、ステートマシンの外側をリファクタリングする必要がある。
② データベース・API連携時の型安全性の担保
CSVから抽出したデータは、すべて「Variant (String)」型として扱われる。これをそのままSQLのINSERT文や外部APIのJSONペイロードに突っ込むと、型ミスマッチエラーやSQLインジェクションの脆弱性を生む。
パース後の配列(`arr(i)`)を受け取った直後に、必ずバリデーション(数値チェック、日付フォーマットチェック、文字数制限)を挟むレイヤーを一枚噛ませるのが、堅牢なアーキテクチャの鉄則である。
③ 文字コード(BOM付きUTF-8 / Shift-JIS)の罠
VBScriptの `Scripting.FileSystemObject` は、文字コードの自動判別において脆弱性を抱えている。特にUTF-8(BOMなし)のファイルを読み込ませると文字化けを起こすか、パースの初期段階で予期せぬバイト列を読み込んでステートマシンが狂う。
実務でCSVファイルを扱う際は、事前に「必ずShift-JIS (CP932) もしくは UTF-8 (BOM付き)」にファイルエンコーディングを統一させる前処理、あるいはADODB.Streamを用いた堅牢な読み込みルーチンへの差し替えを検討すべきだ。
—
総括
VBScriptはレガシーな言語と揶揄されることもあるが、OSの標準機能だけで完結し、正しく設計されたアルゴリズムを実装すれば、モダンな言語に引けを取らない堅牢な自動化ツールとして機能する。
「動けばいい」という妥協を捨て、エッジケースを完全に網羅したステートマシンをあなたの武器庫に加えれば、どんなに汚い変則CSVが持ち込まれようとも、恐れるものは何もない。
