はじめに
住所から郵便番号を検索するAPIを作って公開しています。
Address to ZIP
作ってみて分かったのは、「住所を検索する」の本当に面倒な部分は郵便番号データそのものではなく、人間が書く住所の表記ゆれをどう吸収するかだった、ということです。この記事はその実装の話です。
何がゆれるのか
日本郵便のデータは「町名まで」で管理されています。でも実際にユーザーが入力する住所は、だいたいこう崩れます。
東京都千代田区丸の内1丁目2-3 ○○ビル3F
丸の内一丁目
梅田1-1-3 大阪駅前ビル3F
札幌市中央区大通西10丁目
町名(丸の内・梅田・大通西)より後ろに、番地・丁目・建物名・階数がくっついてくる。しかも丁目は算用数字(1丁目)のときも漢数字(一丁目)のときもある。これを町名まで正しく削れないと、データと一致しません。
さらに、全角/半角、ハイフンの種類(‐/-/―/ー)、余分な空白や句読点も揺れます。「東京都 千代田区 丸の内1ー2」のような入力も普通に来ます。
正規化のステップ
1. 文字レベルの正規化
export function normalizeAddress(value = "") {
return String(value)
.normalize("NFKC")
.toLowerCase()
.replace(/[‐-―ー−]/g, "-")
.replace(/\s+/g, "")
.replace(/[.,、。]/g, "");
}
NFKC正規化で全角数字・全角英字を半角に統一し、ハイフンの表記ゆれ(‐/―/ー/−)を全部-に寄せ、空白と句読点を除去します。これだけで「東京都 千代田区 丸の内1ー2」→「東京都千代田区丸の内1-2」になります。
2. 番地・建物名を町名まで削る
ここが一番手間がかかった部分です。丁目・番地・建物名を、正規表現を順番に適用して剥がしていきます。
const KANJI_NUM = "一二三四五六七八九十〇";
const CHOME_RE = new RegExp(`[0-9${KANJI_NUM}]+丁目.*$`, "u");
const MULTI_BLOCK_RE = /[0-9]+(-[0-9]+)+.*$/u;
const BUILDING_RE = /(ビル|タワー|マンション|ハイツ|コーポ|レジデンス|アパート|[0-9]+階|[0-9]+f|b[0-9]+f).*$/u;
const TRAILING_BLOCK_RE = /[0-9]+(番地?|号)?$/u;
export function stripStreetDetails(normalized) {
let s = String(normalized || "");
s = s.replace(CHOME_RE, "");
s = s.replace(MULTI_BLOCK_RE, "");
s = s.replace(BUILDING_RE, "");
let prev;
do {
prev = s;
s = s.replace(TRAILING_BLOCK_RE, "");
} while (s !== prev && s.length > 0);
return s;
}
ポイントは「算用数字の丁目」だけでなく「漢数字の丁目」も同じ正規表現で拾っていることと、末尾の単独番地(TRAILING_BLOCK_RE)を1回では削りきれないケースがあるのでループで剥がしきっていることです。
丸の内1丁目2-3 ○○ビル3F → 丸の内
丸の内一丁目 → 丸の内
梅田1-1-3 大阪駅前ビル3F → 梅田
一番町1-2 → 一番町 ← 町名自体の数字は残る
最後の「一番町」が地味に大事で、「町名の一部として数字っぽい文字が入っているケース」を誤って削らないよう、末尾からの剥がし方に寄せています。
3. 完全一致ではなくスコアリング
町名まで削れても、住所は表記が完全には揃いません。なので「完全一致」ではなく、正規化後の文字列同士の包含関係でスコアを付けています。
function scoreRecord(record, normalizedQuery) {
const exactAddress = normalizedQuery === record.normalizedAddress;
const queryContainsRecord = normalizedQuery.includes(record.normalizedAddress);
const recordContainsQuery = record.normalizedAddress.includes(normalizedQuery);
// ...
}
丁目除去前・除去後の両方のクエリでスコアを取り、高い方を採用します。これによって「東京都千代田区丸の内1丁目2-3 ○○ビル3F」のようなフル住所でも、「丸の内」だけの短い入力でも同じレコードにヒットします。
ただし単純な部分一致だけだと、無関係なレコードが紛れ込みます(例: 「北」という文字だけ一致して秋田県のどこかの地名がヒットする、など)。これを防ぐために、最高スコアに対する相対足切り(RELATIVE_FLOOR)を入れています。トップから大きく離れたスコアの候補は結果から落とします。
4. かな入力にも同じ考え方を適用する
「ちよだく」「まるのうち」のようなひらがな入力、あるいは半角カナ入力にも対応しています。
export function normalizeKana(value = "") {
return String(value)
.normalize("NFKC")
.replace(/[ぁ-ゖ]/g, (c) => String.fromCharCode(c.charCodeAt(0) + 0x60))
.replace(/[ー−‐-―]/g, "ー")
.replace(/[\s・・]/g, "");
}
ひらがなをカタカナに畳み込み、半角カナはNFKCで全角カナへ、長音記号のゆれも統一。データ側のkanaフィールド(全角カタカナ)と突き合わせます。「まるのうち1丁目」のように、かな入力に丁目が混じっても、同じstripStreetDetailsをかけてヒットさせています。
まとめ
住所の表記ゆれ吸収は、結局のところ
- 文字レベルの正規化(全角/半角・ハイフン・空白)
- 番地/建物名を町名まで削る(正規表現の積み重ね)
- 完全一致に頼らず、包含関係のスコアリングで拾う
- かな入力も同じ土台の上で扱う
の積み重ねでした。どれも単体では大した処理ではないのですが、実データの荒れ方に対して一つずつ潰していく必要があり、これを自前でやるのが地味に面倒、というのがAPIを作った動機でもあります。
実際の挙動はドキュメントのライブ実行で試せます。興味があれば触ってみてください。
- サイト: https://addresstozip.jp
- 使い方ガイド: https://addresstozip.jp/guide
- APIリファレンス: https://addresstozip.jp/docs