日本の住所をユーザーに入力させるとき、私が踏んだ7つの罠(実コードつき)
「住所を入力してもらって、その地点の情報を返す」だけの機能を作りました。
やることは単純に見えます。入力を受け取り、ジオコーディングして緯度経度にし、
その座標でハザード情報のポリゴンと照合する。以上です。
実際にやってみると、壊れるのはジオコーディングでもポリゴン照合でもなく、
その手前の「住所文字列をどう扱うか」でした。 しかも壊れ方が静かで、
例外も出ないしテストも緑のまま、ただ結果が間違っているか、
あるいは正しい住所を入れた人が送信ボタンを押せないだけです。
この記事は、実際に自分のサービスで踏んだ罠と、それに対して書いた実コード・
実テストの記録です。TypeScript ですが、考え方は言語に依存しません。
前提として、扱う住所は以下2つに分けています。これは最初から分けておいて
正解でした(理由は罠7で書きます)。
export interface NormalizedAddressInput {
/** 分析・ジオコーディングに使う住所 */
analysisAddress: string;
/** 画面表示に使う住所 */
displayAddress: string;
/** 分離した建物名。無ければ null */
buildingName: string | null;
}
罠1: 「市区町村どまり」を弾く正規表現が、実在する町名を弾いていた
最初にやりたかったのは「大阪市」だけ入力された場合を弾くことです。
市だけだと、ジオコーディングの結果は数km四方の中心1点になります。
その1点でハザードポリゴンと照合して「この市は該当しません」と出したら、
それは嘘です。だから判定を出さずに、もう少し詳しく入れてもらう。
無駄な外部 API 課金も防げます。
最初にこう書きました。
// ❌ 最初の実装
if (/^.{1,6}[市区町村]$/u.test(address)) return true; // 曖昧すぎる
「6文字以内で市区町村で終わるなら自治体名だけだろう」という発想です。
これで数か月動かしていました。
問題は、日本の町名は「町」で終わるということです。
西宮市松山町 ← 6文字。正規表現に掛かって弾かれていた
尼崎市南塚口町 ← 7文字。こちらは通っていた
同じ「市名 + 町名」という正当な住所なのに、文字数が1文字違うだけで
通るか通らないかが変わっていました。 「西宮市松山町」を入れた人は
「もっと詳しく入力してください」と言われて、そこで終わっていたわけです。
さらに悪いのは、この判定をフォーム側の送信前バリデーションにも使っていたので、
送信ボタンすら押せなかったという点です。サーバ側のログにも何も残りません。
気づいたのは、弾かれた入力を別に記録するようにしてからでした。
直し方: 文字数で数えない。末尾の文字種で見分ける
町名が「市」「区」「郡」で終わることはありません。ここが唯一の手がかりです。
/**
* 「町名まで入っているか」を判定する。**丁目・番地は要求しない。**
*
* 旧実装は `^.{1,6}[市区町村]$` で、町名で終わる短い住所を市区町村どまりと
* 誤判定していた("西宮市松山町" が弾かれていた)。
*/
export function isTooVagueAddress(address: string) {
if (address.length < 4) return true;
// 都道府県だけ
if (/^.{2,3}[都道府県]$/u.test(address)) return true;
// 「〜市」「〜区」「〜郡」で終わる = 自治体・行政区どまり。
// 町名がこれらで終わることはないので誤爆しない。
if (/[市区郡]$/u.test(address)) return true;
// 「〜町」「〜村」で終わるものは曖昧:
// 自治体名(東浦町)か町名(西宮市松山町)か。
// 市または区を含んでいれば、その後ろは町名とみなして通す。
if (/[町村]$/u.test(address) && !/[市区]/u.test(address)) return true;
return false;
}
ポイントは最後のルールです。「〜町」で終わる文字列は、
-
愛知県東浦町→ 町そのものが自治体(弾きたい) -
西宮市松山町→ 市の中の町名(通したい) -
猪名川町白金→ 自治体が「町」で、その中の町名(通したい)
の3通りあります。「市」または「区」を含んでいるかどうかで切り分けられます。
猪名川町白金 は「町」で終わらないので、そもそもこの条件に掛かりません。
文字数で近似しようとしたのが敗因でした。日本の住所は文字数で性質が決まりません。
罠2: そもそも丁目・番地を要求してはいけなかった
これは正規表現の話ではなく仕様判断の話です。
「精度が上がるから」と丁目・番地まで要求したくなりますが、
利用者から見れば住所は個人情報です。 番地まで入れるのは、
サービスを信用していない段階では抵抗があります。
自分のログを確認したところ、入力の約4割が町名どまりでした。
つまり番地を必須にすると、その4割は入力途中で消えます。
そして技術的には、町名まで分かればほぼ足ります。
町の代表点が取れれば、洪水浸水想定区域や液状化傾向図のポリゴンとの照合は
できます(自分の実測では、町名のみの入力で全項目が返りました)。
必要な精度は、要求できる精度ではなく、判定が成り立つ最小の精度で
決めるべきでした。コード上ではこれがコメントとして残っています。
/**
* 町の代表点があればハザード照合はできる
* (実測でも町名のみの入力で 16/16 項目が返る)。
* 一方、市・区どまりだと代表点が数 km 四方の中心 1 点になり、
* 「この区は非該当」と読めてしまうので判定を出さない。
*/
一方で、この妥協には限界もあります。代表点で照合するので、
町の中の一部だけが該当するハザードは取りこぼします。
自分の場合、土砂災害警戒区域は台地の縁の崖線に沿って局所的に指定されるため、
町の代表点ではほとんど拾えませんでした。これは仕様として明示するしかありません。
罠3: 長音符「ー」を一律ハイフンに変換すると建物名が壊れる
全角を半角に寄せる前処理を書きます。ここで、住所の番地区切りに使われる
「1ー2ー3」の長音符もハイフンにしたくなります。
// ❌ 一律変換
.replace(/ー/g, "-")
これをやると、
サンタワーレジデンス → サンタワ-レジデンス
になります。結果として、
- カタカナ連続の判定(
/[ァ-ヶ]{2,}/)が途切れて効かなくなる - 建物名キーワード「タワー」にマッチしなくなる
という2つの判定が同時に壊れます。 しかも例外は出ません。
直し方: 数字に挟まれている時だけ番地区切りとみなす
/**
* 注意: 長音符 U+30FC (ー) を一律ハイフンにしてはいけない。
* 「サンタワーレジデンス」が「サンタワ-レジデンス」になり、
* カタカナ判定も建物語判定も同時に壊れる。
* 数字に挟まれている時だけ番地区切りとみなす。
*/
function toHalfWidth(input: string) {
return input
.replace(/[A-Za-z0-9]/g, (ch) =>
String.fromCharCode(ch.charCodeAt(0) - 0xfee0))
.replace(/[-−‐–—]/g, "-")
.replace(/(?<=[0-9])ー(?=[0-9])/g, "-") // ← ここ
.replace(/ /g, " ")
.replace(/(/g, "(")
.replace(/)/g, ")");
}
(?<=[0-9])ー(?=[0-9]) の lookbehind / lookahead で、前後が数字のときだけ
置換します。ハイフンに見える記号(- − ‐ – —)はどれも住所の区切りなので
一律で構いませんが、長音符だけは違います。
罠4: ユーザーは物件情報をそのまま貼り付ける
自分のサービスは「家を借りる / 買う前に調べる」用途なので、
利用者の手元には必ず物件情報があります。当然こうなります。
大阪府大阪市西淀川区歌島2丁目1-1 グランドメゾン歌島 3LDK 3,280万円
私の建物名分離ロジックは、空白区切りの末尾ブロックだけを見ていました。
const spaceIndex = input.lastIndexOf(" ");
const tail = input.slice(spaceIndex + 1); // = "3,280万円"
末尾は 3,280万円 です。カタカナでもラテン文字でも建物語でもないので
「建物名ではない」と判定され、間取り・価格・建物名がすべて分析用住所に
残りました。
ここが厄介なところで、座標は正しかったのです。 Google のジオコーダは
余計な語を無視して同じ地点に着地させてくれます。だから結果は正しく出ていた。
壊れていたのは、画面に書いてある「マンション名・部屋番号は分析対象から
除外されます」という約束と、DB に価格まで保存されていた事実です。
直し方: 建物名分離の前に「住所ではない要素」を落とす層を1つ足す
function stripListingNoise(input: string) {
return input
// 見出しラベル: 所在地: / 住所:
.replace(/^(?:物件所在地|所在地|所在|住所)\s*[::]?\s*/u, "")
// 【新築】などの装飾。住所には現れない
.replace(/【[^】]*】/gu, " ")
// 区切り文字
.replace(/[/||]/gu, " ")
// 括弧の中に交通・価格・間取りが入っているものだけ落とす。
// 住所の一部が括弧に入ることもあるので、中身で判定する
.replace(/[((][^))]*(?:分|駅|線|万円|LDK|DK)[^))]*[))]/gu, " ")
// 価格
.replace(/[0-9][0-9,.]* *億 *[0-9,]* *万円/gu, " ")
.replace(/[0-9][0-9,.]* *[万億]円/gu, " ")
// 間取り。2SLDK / 3LDK / 1K / 1R
.replace(/(^| )[0-9]{1,2} *[SL]?[LD]?DK(?= |$)/gu, " ")
.replace(/(^| )[0-9]{1,2} *[KR](?= |$)/gu, " ")
// 所要時間
.replace(/(?:徒歩|バス|車) *[0-9]{1,3} *分/gu, " ")
// 面積
.replace(/[0-9][0-9.,]* *(?:m2|m²|㎡|平米|坪)/gu, " ")
// 築年
.replace(/築 *[0-9]{1,3} *年/gu, " ")
.replace(/[0-9]{4} *年 *(?:[0-9]{1,2} *月 *)?築/gu, " ")
.replace(/[ ]+/g, " ")
.trim();
}
括弧の扱いだけ補足します。(青葉台駅バス5分) は落としたいけれど、
住所の一部が括弧に入ることもあります。だから括弧を無条件に消さず、
中身に 分|駅|線|万円|LDK|DK が入っているものだけ落としています。
これで以下が通るようになりました(実際のテストケースです)。
所在地:兵庫県西宮市松山町1-2-3
→ 兵庫県西宮市松山町1-2-3
兵庫県神戸市東灘区本山中町4丁目 / 徒歩8分 / 4,980万円
→ 兵庫県神戸市東灘区本山中町4丁目
横浜市青葉区美しが丘2-1-1(青葉台駅バス5分)
→ 横浜市青葉区美しが丘2-1-1
【新築】大阪市中央区淡路町2-1-1 2SLDK 70.5㎡ 築2年
→ 大阪市中央区淡路町2-1-1
罠5: 除去は「狭く」取る。住所を削るほうが害が大きい
罠4の続きです。ノイズ除去を書いていると、だんだん強くしたくなります。
「〜駅」を含むブロックは全部落とそう、とか。
やってはいけません。実在する町名に「駅」が入っています。
愛知県名古屋市中村区名駅1丁目 ← 名古屋の「名駅(めいえき)」は町名
北海道札幌市北区北8条西3丁目 ← 「条西3丁目」を削ったら終わり
東京都 世田谷区 三軒茶屋 ← 単なる分かち書き。建物名ではない
これを削ると、正しい住所を入れた人が誤った地点で判定されます。
ノイズが残るのは見栄えが悪いだけですが、住所を削るのは結果を間違えます。
非対称なので、迷ったら削らない側に倒します。
「〜駅」のブロックを落とすルールは、最終的にこうなりました。
// ここまでで交通の所要時間は消えているので、残った「〜駅」だけの
// 独立したブロックを落とす。ただし住所側に番地らしい数字が残っている場合に限る
// ("名駅1丁目" は 丁目 で終わるのでこの条件に掛からない)
.replace(/ [^ ]{1,12}駅(?= |$)/gu, (matched, offset, whole) =>
/[0-9]/u.test(whole.slice(0, offset)) ? " " : matched,
)
- 空白で区切られた独立ブロックだけを対象にする(
名駅1丁目は掛からない) - かつ、その手前に数字があるときだけ落とす
(住所として成立している部分が既に確定している証拠として使う)
建物名分離も同じ思想で、判定に当てはまらなければ何も削りません。
// head 側に番地らしい数字が残っていることを条件にする。
// 「東京都 世田谷区 三軒茶屋」のような単なる分かち書きを壊さないため。
const headHasNumber = /[0-9]/u.test(head);
if (tailLooksLikeBuilding && headHasNumber) {
return { address: head, building: tail };
}
罠6: 本番で通った入力を、そのまま回帰テストに固定する
罠1の「弾く条件を直す」は、弾く側に広がると以前は使えた人が使えなくなる
性質の変更です。これを守るために、本番で実際に完了した住所を
テストの「通る側の基準」として固定しました。
/**
* この判定はフォーム側でも送信前に使っている。
* 弾く側が広がると、以前は通っていた住所が送信すらできなくなるので、
* **本番で実際に完了した住所**を通る側の基準として固定しておく。
*/
test("本番で完了した住所は通る", () => {
for (const address of [
"東京都大田区池上3丁目",
"大阪市中央区森ノ宮中央2丁目",
"大阪市中央区船越町",
"大阪府堺市北区北花田",
// ...
]) {
assert.equal(
isTooVagueAddress(normalizeAddressInput(address).analysisAddress),
false,
address,
);
}
});
対になる「削らない」テストも置いています。
/**
* **除去は狭く取る。** 住所の一部を削るほうが、余計な語が残るより害が大きい。
* ここが壊れると、正しい住所を入れた人が誤った地点で判定される。
*/
test("物件情報の除去が住所を削らない", () => {
for (const raw of [
"北海道札幌市北区北8条西3丁目", // 条丁目
"愛知県名古屋市中村区名駅1丁目", // 駅を含む実在の町名
"愛知県名古屋市中村区名駅",
"東京都 世田谷区 三軒茶屋", // 単なる分かち書き
]) {
assert.equal(normalizeAddressInput(raw).analysisAddress, raw, raw);
}
});
住所の正規化は、正しく変換できたかを人間が目で確かめられる領域です。
実例を並べるテストが一番効きます。抽象的な性質テストよりも、
「この文字列がこの文字列になる」を並べたほうが、後から読んだ自分に伝わります。
なお、公開時に注意すべき点として、本番の入力をそのままテストに書くなら、
番地まで含む住所は誰かの家である可能性があります。 リポジトリが private
でも、記事やスクリーンショットに出すときは町名どまりに加工してください
(この記事でも一部を架空の住所に差し替えています)。
罠7: 表示用の住所も正規化する。ただし建物名は残す
冒頭で analysisAddress と displayAddress を分けた話をしました。
分けた理由は「入れたものと違うものが表示される」不信を避けるためで、
最初は displayAddress にユーザーの元入力をそのまま入れていました。
これも間違いでした。物件情報を貼られると、
- 結果画面の見出しに
3LDK 3,280万円まで出る - 購入確認の画面にも出る
- DB にもそのまま保存される
最終形はこうです。
return {
analysisAddress: address.trim(),
// **表示用も整形後を使う。**
// 建物名は残すので、利用者が自分の入力を見分けられなくなることはない。
// 全部落ちてしまった場合だけ元入力に戻す。
displayAddress: cleaned || displaySource,
buildingName,
};
整形後を表示に使いますが、建物名は残します。 建物名が残っていれば
「自分が入れたものだ」と分かるので、元入力を保持する目的は達成できます。
落とすのは価格・間取り・所要時間だけです。
そして cleaned || displaySource のフォールバックを入れておきます。
除去ルールが想定外の入力で全部消してしまった場合に、空文字を表示するよりは
元入力を出すほうがましです。
まとめ
日本の住所を扱うときに、自分が繰り返し使うことになった判断基準です。
- 文字数で住所の性質を判定しない。 末尾の文字種で見分ける
-
「〜町」で終わる文字列は自治体名か町名か決まらない。
「市」「区」を含むかで切り分ける - 長音符を一律ハイフンにしない。 数字に挟まれたときだけ
- ユーザーは物件情報をそのまま貼る。 前提にして設計する
- 除去は狭く取る。 住所を削る害 > ノイズが残る害。この非対称性を忘れない
- 要求する精度は、判定が成り立つ最小限にする。 住所は個人情報
- 表示用と分析用を分ける。 ただし表示用も整形する。建物名だけ残す
最後にもう一度書いておきたいのは、これらの不具合はどれも例外を出さなかった
ということです。ジオコーダが賢く吸収してくれるので座標は正しく、
テストも緑のまま、ただ画面の約束が守られていなかったり、
正しい住所を入れた人が送信できなかったりしていました。
住所の正規化は「動いているか」ではなく「何を削って何を残したか」を
目で確認する必要があります。実例を並べたテストを書いてください。
それが一番安く、一番効きます。