1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

はじめに

住所から郵便番号を検索する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をかけてヒットさせています。

まとめ

住所の表記ゆれ吸収は、結局のところ

  1. 文字レベルの正規化(全角/半角・ハイフン・空白)
  2. 番地/建物名を町名まで削る(正規表現の積み重ね)
  3. 完全一致に頼らず、包含関係のスコアリングで拾う
  4. かな入力も同じ土台の上で扱う

の積み重ねでした。どれも単体では大した処理ではないのですが、実データの荒れ方に対して一つずつ潰していく必要があり、これを自前でやるのが地味に面倒、というのがAPIを作った動機でもあります。

実際の挙動はドキュメントのライブ実行で試せます。興味があれば触ってみてください。

1
0
3

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?