KEN_ALLについて本気出して考えてみた

この記事は公開されてから半年以上経過しています。 情報が古い可能性がありますのでご注意ください。

KEN_ALL は、日本郵便株式会社が公開している全国の郵便番号と住所のデータベースです。郵便番号のリストとそれに対応する住所がマッピングされていて、ユーザーに住所を入力させるフォームによくある、「入力された郵便番号を元に住所の欄を自動入力させる」ボタンの実装に良く使われています。

ところがこの KEN_ALL.csv は解析が困難である事で良く知られており、数多のエンジニアが様々な言語でデータを解析するパーサーをがんばって実装しています。GitHub で kenall などのキーワードで検索すれば山ほどヒットすることでしょう。

(突然の宣伝失礼しますが)わたしが運営している zipcoda も当然 KEN_ALL.csv のデータを元に動いているWebサービスです。このサービスの特徴は「郵便番号から住所」だけでなく、「住所から郵便番号」という逆引きも可能なところです。

なぜいま再発明したのか

もともと zipcoda は他の方が作られたパーサーに依存していました。最初期(2014年)は yubinbango-data のリポジトリのデータを加工して使用し、そして2020年からはGo言語で実装されたパーサーを利用してデータを生成していました。そんな折、こんなフィードバックが寄せられたのです。

愛媛県松山市空港通ですが「丁目」によって郵便番号が異なります。
現在は多分、愛媛県松山市空港通の文字列から「7900054」になっている様に見えるのですが
下記のように修正いただくことは可能でしょうか?(後略)

さて、困りました。解析されたデータが間違っているのか、それとも検索のアルゴリズムに問題があるのかすぐに判断がつきませんでしたし、調査するにしてもパースを他に丸投げしている状況では把握コストが非常に高く付きます。そして、 「わたしは KEN_ALL のことをなにもわかっちゃいなかった」 と痛感したのでした。

そんなわけで、わたしは KEN_ALL を完全に理解するためにパーサーを再発明しようと考えたわけです。つまりただの好奇心。

ここが変だよ KEN_ALL

ここからは、パーサーを実装しながら出会った、KEN_ALL の「なんだこれふざけんな」ポイントを列挙していきます。なお、例示のデータは見やすいようにフリガナのカラムを省略しています。公開されている郵便番号データの説明を踏まえてご覧ください。

1. 複数行にわたるレコード

01224,"066  ","0660005","北海道","千歳市","協和(88-2、271-10、343-2、404-1、427-",1,0,0,0,0,0
01224,"066  ","0660005","北海道","千歳市","3、431-12、443-6、608-2、641-8、814、842-",1,0,0,0,0,0
01224,"066  ","0660005","北海道","千歳市","5、1137-3、1392、1657、1752番地)",1,0,0,0,0,0

これはKEN_ALLが抱える問題の元凶の9割は占めるでしょう。仕様によれば

全角となっている町域部分の文字数が38文字を越える場合、また半角となっているフリガナ部分の文字数が76文字を越える場合は、複数レコードに分割しています。

ということのようですが、この仕様がパーサー実装の重要性と難易度を上げまくっていると言って良いです。現時点では、すべての複数行レコードは例示のように「括弧書きが長すぎるため」に複数行になっています。括弧書きの解析も困難を極めますが、それはまた別の話。

2. 住所ではない文字列

# 高層棟
23105,"450  ","4506290","愛知県","名古屋市中村区","名駅ミッドランドスクエア(高層棟)(地階・階層不明)",0,0,0,0,0,0
23105,"450  ","4506201","愛知県","名古屋市中村区","名駅ミッドランドスクエア(高層棟)(1階)",0,0,0,0,0,0

# ~を除く, ~を含む
11105,"338  ","3300081","埼玉県","さいたま市中央区","新都心(次のビルを除く)",0,0,0,0,0,0
38205,"792  ","7920846","愛媛県","新居浜市","立川町(立川山を含む)",0,0,0,0,0,0

# その他
01106,"005  ","0050863","北海道","札幌市南区","常盤(その他)",1,0,0,0,0,0

# ~以上
04205,"988  ","9880927","宮城県","気仙沼市","唐桑町西舞根(200番以上)",1,0,0,0,0,0

# ~場合
01101,"060  ","0600000","北海道","札幌市中央区","以下に掲載がない場合",0,0,0,0,0,0
08546,"30604","3060433","茨城県","猿島郡境町","境町の次に番地がくる場合",0,0,0,0,0,0

上の例のように、住所ではなく郵便番号が示す範囲を説明するための文字列が書いてある場合があります。住所の自動入力でこれらの文字列が挿入されてしまっては大変困るので、削除等しておかないといけません。

3. どうしてほしいのかわからない

# (丁目|番地)
01663,"08816","0881646","北海道","厚岸郡浜中町","姉別(丁目)",0,0,0,0,0,0
07501,"96378","9637884","福島県","石川郡石川町","新屋敷(番地)",1,0,0,0,0,0

# 甲、乙
37322,"76141","7614103","香川県","小豆郡土庄町","甲、乙(大木戸)",1,0,0,0,0,0
37322,"76141","7614112","香川県","小豆郡土庄町","甲、乙(鹿島)",1,0,0,0,0,0

丁目(あるいは番地)がつく住所かどうかで郵便番号が変わるんでしょうか。

後者は、「小豆郡土庄町甲」「小豆郡土庄町乙」と展開されるのか、そして「大木戸」や「鹿島」はその両方に付与されるものなのか。謎が深まります。

4. 様々な括弧

# 「 」
03202,"02825","0282504","岩手県","宮古市","箱石(第2地割「70~136」~第4地割「3~11」)",1,1,0,0,0,0

# 〔 〕
07543,"97906","9790622","福島県","双葉郡富岡町","毛萱(前川原232~244、311、312、337~862番地",1,1,0,0,0,0
07543,"97906","9790622","福島県","双葉郡富岡町","〔東京電力福島第二原子力発電所構内〕)",1,1,0,0,0,0

# ダブル括弧
23105,"450  ","4506290","愛知県","名古屋市中村区","名駅ミッドランドスクエア(高層棟)(地階・階層不明)",0,0,0,0,0,0

括弧は丸括弧 ( ) だけではなく、 鉤括弧 「 」 が使用されることがあります。鉤括弧は丸括弧の中で入れ子で使われ、丸括弧よりさらに詳細な条件を示します。つまり丸括弧とは親子関係にあるようです。

呼び方がわからないこちらの括弧 〔 〕 は、現在では「〔東京電力福島第二原子力発電所構内〕」でしか使われていない最レアキャラです。こんな表記ゆれしそうな文字を使ったのは、「他では使われない」ということを暗に示しているのでしょうか。

5. 範囲記号が組み合わされるケース

01646,"08936","0893672","北海道","中川郡本別町","追名牛(67-4~113-7番地)",1,0,0,0,0,0
01207,"08023","0802333","北海道","帯広市","美栄町(西5~8線79~110番地)",1,0,0,0,0,0

これもまた「どうしてほしいのかわからない」案件なのですが、範囲記号が複数組み合わされて、実際に示されている範囲がわからなくなっています。

例えば「中川郡本別町」のケースは、「68ー9番地」や「113-3番地」は含まれるのか、そもそも番地はいくつまであるのか。また、「帯広市」のケースは「西6線78番地」や「西6線111番地」などは範囲に含まれるのか、判断に迷います。

6. 複数町域にまたがる郵便番号

01108,"004  ","0040000","北海道","札幌市厚別区","以下に掲載がない場合",0,0,0,1,0,0
01110,"004  ","0040000","北海道","札幌市清田区","以下に掲載がない場合",0,0,0,1,0,0

これは郵便番号データの説明でも仕様として説明されているものです。郵便番号が町域をまたぐ場合は、レコードの13カラム目の「一つの郵便番号で二以上の町域を表す場合の表示」が 1 になります。(上の例示はフリガナのカラムを省略しているので10カラム目になります)

さて、これを踏まえて次へ進みます。

7. 都道府県を越える郵便番号

23235,"498  ","4980000","愛知県","弥富市","以下に掲載がない場合",0,0,0,1,0,0
24303,"498  ","4980000","三重県","桑名郡木曽岬町","以下に掲載がない場合",0,0,0,1,0,0

26303,"618  ","6180000","京都府","乙訓郡大山崎町","以下に掲載がない場合",0,0,0,1,0,0
27301,"618  ","6180000","大阪府","三島郡島本町","以下に掲載がない場合",0,0,0,1,0,0

40642,"871  ","8710000","福岡県","築上郡吉富町","以下に掲載がない場合",0,0,0,1,0,0
44203,"871  ","8710000","大分県","中津市","以下に掲載がない場合",0,0,0,1,0,0

前述の通り郵便番号は町域をまたぐのですが、さらに都道府県をまたぐ場合があります。2023年4月現在では、例にあげた3件だけが見つかりました。地図で確認したところ、エリアとしては隣接している様でした。きっと何かしらの歴史的経緯があるのでしょう。

ではこれらをどう料理するか

列挙したこれらのハードルを乗り越えつつパーサーを実装していくわけですが、整理すると対応しなければならない課題は意外に少ない事に気付きます。「どうしてほしいのかわからない」あるいは「どうしようもない」物は潔く削除するとして、残るは複数行問題と、括弧書きの処理です。

複数行はどうまとめるか

まず複数行にわたるレコードをどうまとめるかです。前述のとおり、現時点では全ての複数行レコードは括弧内の情報が長すぎるせいでそのようになっているので、「括弧がはじまったら、閉じ括弧が来るまでひとつのレコードとしてまとめる」 という手段が有効です。

しかしこの実装はひとつ懸念があります。現在はありませんが「括弧使ってないけどめちゃくちゃ長い住所」が出てきた場合に対応できないのです。要するに、将来的にこのルールは通用しなくなるかもしれない。「浜松町世界貿易センタービルディング南館」のように建物名が住所に含まれる場合があり、ものすごく長い名前のビルが建てられるかもしれないと考えると、その可能性はそんなに低くないように思えます。

そこで、現在の行と次の行を比較して、次の条件を満たす場合に同じレコードであると見なすようにしてみましょう。

  1. 郵便番号が等しい
  2. 都道府県が等しい
  3. 市区町村が等しい
  4. 「一つの郵便番号で二以上の町域を表す場合の表示」が 0 である

コードで書くとこんな感じになるでしょう。

function getIsSameRecord (current: string[], next: string[]):boolean {
  return current[12] === "0" 
    && current[2] === next[2] 
    && current[6] === next[6] 
    && current[7] === next[7]
}

これと括弧の条件をあわせて判別すれば安心できそうです。

括弧内の情報を展開するか否か

次に括弧内の情報をどうするか。「どうしてほしいのかわからない」記述や「住所ではない文字列」は全て削除するとして、次のようなオーソドックスなパターンを展開するかどうかを考えます。

ペンギン村(100、200-202、300-302)

これを展開すると

ペンギン村100
ペンギン村200
ペンギン村201
ペンギン村202
ペンギン村300
ペンギン村301
ペンギン村302

このような具合になります。つまり、一つのレコードからねずみ算式に行が増え、データのレコード数は爆発的に増えることでしょう。

実際に展開の実装をされているパーサーもあるのですが、展開するべきかどうかはそのデータの用途に依ります。KEN_ALL をパースする目的のほとんどは郵便番号からの住所検索だと思いますが、少なくともその目的においてはデメリットの方が勝ります。検索の結果何十何百という候補が返されても、どれを選択したらよいのかわからず困ってしまいますよね。

よって、ここでは展開するべきではないと結論づけておきます。

実装してみたKEN_ALLパーサー

さて、こちらが習作してみたパーサーです。

ここで JavaScript を採用した理由は、単に現行のサーバーサイドの環境が node.js だからというだけで、どちらかというとこのような処理は JavaScript が得意とするところではなさそうだなとは思っています。そのうち他の言語でもエクササイズ代わりに実装してみるかもしれません。

kenall-parser はこんな感じに使います。

const KenAll = require('kenall-parser');
const fs = require('fs');

async function save () {
  // データをダウンロードしてCSVを文字列として取得
  const raw = await KenAll.fetch();
  // データをパースする
  const data = KenAll.parse(raw);
  // ローカルに保存
  fs.writeFileSync('./data.json', JSON.stringify(data), 'utf-8');
}

save();

kenall-parser には fetch() なんていうものが備わっていて、日本郵便のWebサイトから KEN_ALL.zip をダウンロードしてきて解凍して文字コード変換して文字列として全取得するところまでやってくれます。作っておいてなんですが、これは node.js でやらずにコマンドにでも投げた方がいいと思います。メモリがも゛っだいな゛い。

生のCSVデータを parse() に食べさせると、パースしてデータを生成してくれます。あとはそれをローカルに保存するなりして好き勝手に使います。ちなみにデータのレコードはこんな型になっています。

interface AddressItem {
  zipcode: string
  pref: string
  components: string[]
  address: string
  notes?: string
}

上で述べた「括弧内の情報の展開」は、はじめは展開するつもりで実装していたのでオプションの機能として残してありますが、きっと使われることはないでしょう。わたしは使いません。

未来の KEN_ALL に望むこと

KEN_ALL が誕生してから現在に至るまでの長い間メンテナンスを続けてくださったこと、そしてこれからもそれが継続されるであろうことに頭が下がる思いです。大変多くの方々がこのデータに助けられていることと思いますし、だからこそおいそれとデータ構造の改修もできないのだと推察します。

ですので、これは本来望むべくもない事ではあるのですが、もし仮に KEN_ALL v2.0 的な物が誕生した場合に、こうなっててくれていると嬉しいなという小さな望みをここに書き捨てておきます。

  1. Unicodeにしてほしい
  2. 1レコード1行にしてほしい
  3. それが無理ならレコードにユニークなIDをふってほしい
  4. それも無理なら「このレコードは複数行にわたります」フラグをつけてほしい

レコードが複数行にわたらないようにするか、それが難しいなら複数行である事が容易に判断がつく構成にしてほしい。望むのはこれだけです。

おそらく現在の KEN_ALL.csv はデータベースか何かの都合で固定長データで住所を記録しなければならないのでしょう。Shift_JIS を使い続けているのもそのためかと。( Shift_JIS では半角カタカナは1バイトであることを今回知りました)

いつの日か、数多のパーサーたちが不要になる日が来ると良いですね。

おまけ : 住所自動入力で注意したいこと

最後におまけとして、住所自動入力フォームを実装する際の注意点について記しておきたいと思います。

候補から選択するUI

郵便番号は町域をまたぎます。それはつまり、ひとつの郵便番号に対して複数の住所の候補がありうるという事です。したがって、郵便番号を入力して即補完ではなく、複数の候補がある場合はそれらの候補から選択するUIがあると良いのではないでしょうか。

候補から選択するUI

自動入力後の住所は編集可能であること

郵便番号を入力して自動入力した後のフィールドは、読み込み専用ではなく編集可能であるべきです。これは町域をまたぐ郵便番号をサポートする狙いもありますが、毎月更新されている KEN_ALL のデータに追従出来ていない可能性と、KEN_ALL のデータ自体が最新でない可能性をフォローするためのものでもあります。

住所は編集可能であること

入力した郵便番号から実際とは違う住所が自動補完されてしまった場合、それらのフィールドが読み込み専用では、ユーザーは正しい住所を入力することができませんね。郵便番号は都道府県すらも越える場合があるので、都道府県の入力欄から編集可能にしておく必要があります。

まとめ

パーサーの実装に挑戦してみることで、KEN_ALL のクセのような物への理解がかなり深まり、KEN_ALL筋が鍛えられたように思います。あまり他所では役に立たなそうな筋肉ですが、きっとそういう筋肉は沢山あって、それらを束ねていくことで基礎が出来上がっていくのではないでしょうか。知らないけれどきっとそう。

現場からは以上です。