Excelの「重複の削除」は、セルの内容が完全に同じ行を整理するには便利です。しかし、次のような表記揺れは別の値として残ります。
齋藤 太郎 / 斎藤太郎
神宮前1丁目2番3号 / 神宮前1-2-3
090-1234-5678 / 09012345678
そこで、CSVをサーバーへ送らず、ブラウザ内で正規化・比較・採点する小さなデモをReactとTypeScriptで作りました。本記事では実装の中心部分と、実運用へ進む前に必要な安全策をまとめます。
処理の流れ
処理は次の4段階です。
-
<input type="file">でCSVを選ぶ -
File.text()でブラウザ内へ読み込む - 氏名・住所・電話番号・メールを比較用の表記へ正規化する
- 複数項目の一致を採点し、しきい値以上の組だけ候補として表示する
デモでは計算量と誤操作の影響を抑えるため、先頭200行だけを対象にしています。選択したCSVの内容をAPIへ送る処理は実装していません。
列名の違いを吸収する
名簿ごとに列名が異なるため、用途別に別名を用意しました。
const aliases = {
id: ["id", "顧客id", "顧客番号", "会員番号", "管理番号"],
name: ["氏名", "名前", "顧客名", "会員名", "担当者名"],
address: ["住所", "所在地", "送付先", "居住地"],
phone: ["電話番号", "電話", "tel", "携帯番号", "携帯"],
email: ["メール", "メールアドレス", "email", "e-mail"],
};
type Row = Record<string, string>;
function findColumn(row: Row, kind: keyof typeof aliases) {
const wanted = aliases[kind];
return Object.keys(row).find((key) =>
wanted.includes(key.trim().toLowerCase())
);
}
この方式は単純ですが、列名が想定外なら一致しません。製品化するなら、候補列を提示し、利用者がマッピングを確認できる画面が必要です。
日本語の表記を比較用に揃える
まず、Unicode正規化、空白・句読点の除去、小文字化を共通処理にします。
function common(value: string) {
return value
.normalize("NFKC")
.toLowerCase()
.replace(/[\s ・,,.。]/g, "");
}
function normalizeName(value: string) {
return common(value)
.replace(/[齋齊斉]/g, "斎")
.replace(/髙/g, "高")
.replace(/﨑/g, "崎")
.replace(/(株式会社|有限会社|合同会社|\(株\)|㈱)/g, "");
}
function digits(value: string) {
return value.normalize("NFKC").replace(/\D/g, "");
}
住所は「丁目・番・号」をハイフンへ寄せます。
function normalizeAddress(value: string) {
return common(value)
.replace(/[一壱]丁目/g, "1-")
.replace(/[二弐]丁目/g, "2-")
.replace(/[三参]丁目/g, "3-")
.replace(/四丁目/g, "4-")
.replace(/五丁目/g, "5-")
.replace(/([0-9]+)番地?([0-9]+)号/g, "$1-$2")
.replace(/([0-9]+)番/g, "$1-")
.replace(/([0-9]+)号/g, "$1")
.replace(/-+/g, "-")
.replace(/-$/, "");
}
これは価値検証用の簡易ルールです。漢数字全般、都道府県の省略、建物名、部屋番号、旧字体の網羅には対応していません。正規化結果をそのまま原本へ書き戻さないことも重要です。
1項目だけで決めず、根拠を残す
同姓同名を誤って統合しないよう、氏名だけの一致では候補にしません。複数項目を加点し、一致した根拠も保存します。
type Match = {
left: Row;
right: Row;
score: number;
reasons: string[];
};
function analyze(left: Row, right: Row): Match | null {
let score = 0;
const reasons: string[] = [];
if (normalizeName(left.氏名) === normalizeName(right.氏名)) {
score += 28;
reasons.push("氏名");
}
if (normalizeAddress(left.住所) === normalizeAddress(right.住所)) {
score += 27;
reasons.push("住所");
}
if (digits(left.電話番号) === digits(right.電話番号)) {
score += 27;
reasons.push("電話");
}
if (common(left.メール) === common(right.メール)) {
score += 30;
reasons.push("メール");
}
if (score < 50) return null;
return { left, right, score: Math.min(99, score), reasons };
}
実際のコードでは空文字同士を一致扱いしないよう、正規化後の値が存在することも確認しています。点数と50点というしきい値に統計的な根拠はなく、あくまでUIと需要を検証するための仮置きです。
全組み合わせ比較の限界
デモでは実装を単純にするため、全行の組み合わせを比較しています。
const matches: Match[] = [];
for (let left = 0; left < rows.length; left += 1) {
for (let right = left + 1; right < rows.length; right += 1) {
const match = analyze(rows[left], rows[right]);
if (match) matches.push(match);
}
}
計算量は O(n²) です。200行なら比較回数は19,900回ですが、1万行では約5,000万回になるため、そのままでは使えません。
件数を増やす場合は、電話番号の下4桁、メールドメイン、郵便番号、氏名の読みなどで比較対象を先に分割する「ブロッキング」が必要です。その後、ブロック内だけを類似度計算すれば候補数を減らせます。
実運用で必要な安全策
名寄せでは、重複の見逃しより「別人を同じ人として統合する誤り」のほうが復旧しにくい場合があります。最低限、次を守る設計にします。
- 元のCSVを上書きしない
- 候補、スコア、一致した項目を別ファイルへ出力する
- 自動統合と要確認のしきい値を分ける
- 空欄同士を一致扱いしない
- 正規化前後の値と処理日時を監査用に残す
- 実データで適合率・再現率を測るまでAIやルールに最終決定させない
動作デモ
実装した簡易デモは以下で試せます。
現在は開発前の需要検証で、ダウンロードできる製品は存在しません。デモは製品版AIではなく、TypeScriptで書いた簡易ルール判定です。CSV解析はブラウザ内で行い、通知登録を送信した場合だけ回答内容が需要集計用データベースへ保存されます。
この記事は、完全自動で需要を検証する実験の一環として、AIによる調査・文章作成支援を利用して作成しました。