背景
CSV の重複行を消すツールを作りました。
作る前は「行で split して Set に突っ込めば終わりでは」と思っていました。
実際、私は普段そうしています(雑です…)。
ただ、今回はサイトに置いて他人にも使ってもらうものなので、一度ちゃんと考えることにしました。
考えたら、雑なやり方だと消えない重複があることに気づきました。
消えない重複の例
下記の CSV を見てください。
id,name,note
1,佐藤,"通常の値"
1,佐藤,通常の値
2行目と3行目は、CSV として読んだ結果はまったく同じです。
id=1 / name=佐藤 / note=通常の値。
けれど行の文字列としては違います。
片方には二重引用符が付いているからです。
Set に生の行を入れると、この2行は別々に残ります。
const seen = new Set();
for (const line of text.split("\n")) {
if (seen.has(line)) continue; // ここで弾けない
seen.add(line);
}
line は改行で切っただけの生の文字列なので、"通常の値" と 通常の値 は当然一致しません。
出力は2行とも残る。私が期待していたのは1行でした。
逆の失敗もある
もうひとつ、行で split すること自体が壊れる例があります。
id,note
1,"複数行の
メモ"
note の中に改行が入っています。
RFC 4180(CSV の書式を定めた文書)では、二重引用符で囲まれたセルの中の改行は「値の一部」であって、行の区切りではないと決まっています。
なので text.split("\n") は、ここで1レコードを2行に割ってしまう。
そのあと何を比較しても、もう正しい答えは出ません。
やったこと
先に構文解析して、セルの配列に直してから比較するようにしました。
下記が使っているパーサです。text は CSV 全体の文字列、delim は "," や "\t" などの区切り文字1文字です。
戻り値は「行の配列」で、各行は「セルの配列」になります。
function parseCSV(text, delim){
var rows = [], row = [], field = '', i = 0, inQ = false, n = text.length;
while (i < n) {
var c = text[i];
if (inQ) { // 引用符の内側
if (c === '"') {
if (text[i+1] === '"') { field += '"'; i += 2; continue; } // "" は " 1つ
inQ = false; i++; continue;
}
field += c; i++; continue; // 内側の改行・区切りは値の一部
}
if (c === '"') { inQ = true; i++; continue; }
if (c === delim) { row.push(field); field = ''; i++; continue; }
if (c === '\r') { i++; continue; }
if (c === '\n') { row.push(field); rows.push(row); row = []; field = ''; i++; continue; }
field += c; i++;
}
if (field !== '' || row.length) { row.push(field); rows.push(row); }
return rows;
}
inQ が「いま引用符の内側にいるか」を持っているだけの、素朴な状態機械です。
内側にいる間は区切り文字も改行もただの文字として field に積むので、上の2つの失敗がどちらも起きません。
先ほどの入力を通すと、こうなります。
入力: 1,佐藤,"通常の値"\n1,佐藤,通常の値
出力: [["1","佐藤","通常の値"], ["1","佐藤","通常の値"]]
配列として完全に一致したので、あとはこれをキーにすれば重複として弾けます。
キーの作り方
セルの配列をそのまま比較はできないので、区切って1本の文字列にします。
このとき、区切りに使う文字が値の中に出てくると誤判定します。
そこで区切りには、ふつうのテキストにまず現れない制御文字であるヌル文字(U+0000。JavaScript の文字列では \u0000 と書く1文字)を使いました。
// row は parseCSV が返した1行ぶんのセル配列(例: ["1","佐藤","通常の値"])。
// trim / ignoreCase は画面のチェックボックスの状態を入れた真偽値。
function keyOf(row){
return row.map(function(v){
var s = String(v);
if (trim) s = s.replace(/^[\s ]+|[\s ]+$/g, ''); // 前後の空白を無視
if (ignoreCase) s = s.toLowerCase();
return s.replace(/\u0000/g, ''); // 値の中のヌル文字は落とす
}).join('\u0000');
}
戻り値は 1\u0000佐藤\u0000通常の値 のような1本の文字列で、これをそのまま連想配列のキーにします。
値の中のヌル文字を落としているのは、区切りに使う文字が値の中に残っていると ["a\u0000b"] と ["a","b"] が同じキーになってしまうためです。
実データに出てくることはまず無いのですが、こういう「まず無い」で刺されるのが怖いので消しています。
列を指定して判定する
実務だと、行全体ではなく特定の列だけで見たいことのほうが多い気がします。
メールアドレスだけで重複を見たい、伝票番号と日付の組で見たい、といった話です。
なので画面から列番号を渡せるようにしました。
入力は人が読み書きする 2,3 の形(1始まり)で、内部では 0 始まりに直します。
// spec は入力欄の文字列(例: "2,3")。width はその表の列数。
function parseCols(spec, width){
var s = (spec || '').trim();
if (!s) return null; // 空欄 = 全列で判定
var out = [];
s.split(',').forEach(function(p){
var num = parseInt(p.trim(), 10);
if (!isNaN(num) && num >= 1) out.push(num - 1);
});
return out.length ? out : null;
}
"2,3" を渡すと [1, 2] が返り、keyOf はそのインデックスのセルだけを見ます。
0 や負値や文字が混ざったら黙って捨てて、全部捨てたら null(=全列判定)に落ちます。
ここで例外を投げないのは、入力途中の中途半端な文字列で画面が止まるほうが困るからです。
そのかわり、存在しない列番号を指定したときは画面に注意書きを出しています。
なぜここまでやるか
「行を Set に入れる」で足りる場面は多いです。
自分が作ったデータを自分で処理するだけなら、私も今後もそうすると思います。
分けたのは、他人のデータを受ける口だからです。
他人の CSV は、引用符が付いたり付かなかったり、セルの中に改行が入ったりします。
そして重複除去は「消えなかった」より「消してはいけないものが消えた」のほうが被害が大きい。
その両方を防ぐには、比較の前に構文として読むしかありませんでした。
実物
ツールはこちらです。ブラウザの中だけで動くので、データはどこにも送られません。
CSV の重複を消すときは、行の文字列ではなくセルの配列を比べる。
引用符と改行がある限り、この一手間は省けません。
本記事はAI補助で執筆した、個人開発の紹介記事です。