OPENLOGI AdventCalendar 20日目担当の細川です。
先日 FizzBuzz 問題どや顔で解くひとなんかよりも "KEN_ALL.csv" をうまく扱える人の方が社会的貢献度高い を見かけまして、自分も社会貢献してみたいな、と思いました。
郵便番号データは多くのサービスにとって最も基礎的なマスタデータですが、その作成は闇に包まれていると聞きました。ふと興味を持ったのが間違いでしたので自作にトライしてみました。
作成したソースはこちらにあります
準備
生成元データは日本郵便の公式サイトにある読み仮名の促音・拗音を小書きで表記するものを使います。ここからあの有名なKEN_ALL.CSV
が入手できます。
このファイルは以下に記載しますようにそのままではシステム的な用途では使いにくいため、使いやすい形に変換する必要があります。かなり手強いという評判ですから、変換用プログラムに使う言語には切れ味に定評のあるHaskellを使います。
Haskell開発環境としては、 vscode と haskell-ide-engine で Haskell 開発環境を構築する などを参考にさせていただき、 stack & haskell-ide-engine に vscode をつないだものを利用しました。私はHaskell普段はあまり触らないのですが、快適な環境が簡単に手に入り感動です。
目標
変換後の郵便番号データの仕上がり具合を判定する材料として、無料で変換済みのものを配布されているzipcloudさんのものを参考にしました。こちらではapiサービスも最新のデータで提供されていて素晴らしいです。
つくりかた
元ファイルの文字コードを変えておく
日本郵便サイトから取得した KEN_ALL.CSV
は文字コードがShift-JISなのですが、Haskellでやる上で色々と面倒な予感がするため、UTF-8にしておきます。スマートにそのまま変換したいところですが、今回の趣旨の中心的なものではないので端折りました。すみません。
KEN_ALL.CSV
の問題点を知る
zipcloudさんのサイトにKEN_ALL.CSV
からの変換点として下記の記載があります。
- 町域名が「以下に掲載がない場合」の場合は、ブランク(空文字)に置換
- 町域名が「○○市(または町・村)の次に番地がくる場合」の場合は、ブランク(空文字)に置換
- 町域名が「○○市(または町・村)一円」の場合は、ブランク(空文字)に置換
- 町域名で、丸括弧で囲まれている部分を除去
つまり、このファイルの変換で問題になるのは町域名
という、CSVの9番目の項目(半角カナは6番目)の取扱だけ、ということになります。
上の変換項目だけ眺めると、単純にパターンマッチして置換すれば良さそうに見えますが、最後の町域名で、丸括弧で囲まれている部分を除去
には注意書きがありまして、
※町域名の文字数が多いために複数行に分割されてしまっている場合は、1行にマージしておいてから丸括弧を除去します。
※括弧内の文字が「地名」や「ビルの階」など、住所として使えそうなものは町域名の末尾に追加します。
※括弧内に地名が複数列挙されている場合は複数行に分割します。
とのこと。実際に見てみると、町域名だけが下記のように複数行にまたがっているものが存在します。
"協和(88-2、271-10、343-2、404-1、427-"
"3、431-12、443-6、608-2、641-8、814、842-"
"5、1137-3、1392、1657、1752番地)"
つまり、
- このCSVファイルは1行が1データとは限らないだけではなくて、1行のCSVレコードとして完結すらしてない場合がある
- 1行のCSVレコードは
町域名
に括弧が無い、あるいは、両括弧が揃うまで町域名をマージした複数行によって得られる -
町域名
括弧内の文字列は複数の町域名を表現している場合があり、その場合は更に分割された町域名ごとに同一郵便番号の複数レコードを生成する必要がある
というCSVデータとしては結構ダイナミックな取扱が必要ということになります。ここを制覇できるかどうか、つまりいかに町域名
を取り扱うかがKEN_ALL.CSV
の問題点と言えそうです。
CSVレコードを読み込む
今回HaskellでのCSVパーサーにはcassavaを使ってみました。丸っとcsvレコードをレコード形式のカスタム型への変換するならば、data宣言にderiving (Generic)
した上でFromRecord
やToRecord
のinstance宣言だけすれば、基本的な型の値については変換の実装不要になります。もちろん個別実装も可能です。また、今回は使いませんでしたが、FromNamedRecord
とToNamedRecord
のinstance実装することで、ヘッダーの名称からencode & decodeすることもできるようです。
今回は適当なフィールド名で全て取り込むことにします。
{-# LANGUAGE DeriveGeneric #-}
import Data.Csv
import GHC.Generics ( Generic )
data Postcode = Postcode
{ jis :: !String
, postcode5 :: !String
, postcode :: !String
, prefectureKana :: !String
, cityKana :: !String
, townAreaKana :: !String
, prefecture :: !String
, city :: !String
, townArea :: !String
, isOneTownByMultiPostcode :: !Int
, isNeedSmallAreaAddress :: !Int
, isChome :: !Int
, isMultiTownByOnePostcode :: !Int
, updated :: !Int
, updateReason :: !Int
}
deriving (Generic, Show, Eq)
instance FromRecord Postcode
instance ToRecord Postcode
今回は読み込み->変換->CSV出力をしてみたいと思いますので、メインの処理は下記のようになりました。V.foldM_ (convert' output) (Nothing, Nothing) records
のところが中心となる変換処理になります。fold結果の型は(町名域文字列の括弧が閉じてない前のデータ, 郵便番号変更あった行のデータ)
で、出力データの判定に使用します。
import qualified Data.ByteString.Lazy as BL
import qualified Data.Vector as V
import System.IO
convert :: FilePath -> FilePath -> IO ()
convert input output = do
csvData <- BL.readFile input
BL.writeFile output BL.empty
case decode NoHeader csvData of
Left err -> putStrLn err
Right records -> V.foldM_ (convert' output) (Nothing, Nothing) records
町域名に基づいたCSVレコードのマージ
ここからが本丸です。まずは複数行にまたがった町域名をマージして、CSVレコードとして完結したものにしてから、各種置換などの処理を行う必要があります。下記はparseされたcsvをレコードごとにfoldする関数になりますが、町域名(townArea)
を見て括弧が開いて閉じてない状態の場合は次の畳込み(fold)に値を回し、次行とマージ、括弧が閉じてからencodeして出力します。
出力の際には、変換後の値が前回出力行と重複するケースがあるため、比較してフィルタをするようにしています。
ちなみにHaskell上での正規表現関連は今回 regex-tdfa と regex-compat-tdfa を使ってみました。Unicodeをサポートしています。
regexUnClosedParentheses :: String
regexUnClosedParentheses = "(.*[^)]$"
regexUnClosedParenthesesKana :: String
regexUnClosedParenthesesKana = "\\(.*[^\\)]$"
convert'
:: FilePath
-> (Maybe Postcode, Maybe Postcode)
-> Postcode
-> IO (Maybe Postcode, Maybe Postcode)
convert' output (lastUnClosed, lastChanged) current
| isUnClosed target = return (Just target, lastChanged)
| otherwise = write targetToWrite >> return (Nothing, nextLastChanged)
where
target = maybe current (const merged) lastUnClosed
merged = current
{ townArea = (townArea . fromJust $ lastUnClosed) ++ townArea current
, townAreaKana = (townAreaKana . fromJust $ lastUnClosed)
++ nextUnClosedKana
}
targetToWrite = filter isNotDup $ postCodeWithTownArea target
isNotDup p = maybe True (not . isSameAddress p) lastChanged
nextUnClosedKana
| isUnClosedKana . fromJust $ lastUnClosed = townAreaKana current
| otherwise = ""
isUnClosed p = townArea p =~ regexUnClosedParentheses :: Bool
isUnClosedKana p = townAreaKana p =~ regexUnClosedParenthesesKana :: Bool
write = BL.appendFile output . encode
isSamePostcode p =
maybe False (\lp -> postcode lp == postcode p) lastChanged
nextLastChanged | null targetToWrite = lastChanged
| isSamePostcode $ head targetToWrite = lastChanged
| otherwise = Just $ head targetToWrite
isSameAddress :: Postcode -> Postcode -> Bool
isSameAddress p p' = all (\f -> f p == f p') fields
where
fields =
[ postcode
, prefectureKana
, cityKana
, townAreaKana
, prefecture
, city
, townArea
]
変換処理
次は町域名に従ったレコードごとの変換処理をします。町域名の変換後に、複数の町域名が生成される可能性があるので、その数分だけ町域名違いのレコードデータを生成します。すぐ後に記載しますが、町域名に何か書いてあっても町域名ではない場合があるので、その場合は町域名空文字にレコードを生成します。
postCodeWithTownArea :: Postcode -> [Postcode]
postCodeWithTownArea p =
map (\(ta, taKana) -> p { townArea = ta, townAreaKana = taKana })
$ convertTownArea p
町域名変換
町域名無し
まずは町域名が無しと考えるべきケース。これはzipcloudさんの解説を参考にしてますが、以下に掲載がない場合
, 〜(市|町|村)の次に番地がくる場合
, 〜(市|町|村)一円
がマッチする場合には町域名無しと考えます。
regexIgnore :: String
regexIgnore = "以下に掲載がない場合|(市|町|村)の次に番地がくる場合|(市|町|村)一円"
convertTownArea :: Postcode -> [(String, String)]
convertTownArea p
| ta =~ regexIgnore
= [("", "")]
...
括弧の中がビルの階層
中央アエル(1階)
といった場合は、括弧を取ってあげます。
regexFloor :: String
regexFloor = "(([0-9]+階))"
regexFloorKana :: String
regexFloorKana = "\\(([0-9]+カイ)\\)"
convertTownArea :: Postcode -> [(String, String)]
convertTownArea p
...
| ta =~ regexFloor
= [(replace regexFloor ta " \\1", replace regexFloorKana taKana "\\1")]
...
地割
の対応
岩手県に特有らしいのですが、地割り
という表記があり、穴明22地割、穴明23地割
や葛巻(第40地割「57番地125、176を除く」~第45地割)
という町域名が現れる場合があります。これをそれぞれ穴明
、葛巻
に変換します。
regexJiwari :: String
regexJiwari = "^([^0-9第(]+)[第]*[0-9]+地割.*"
regexJiwariKana :: String
regexJiwariKana = "^([^0-9\\(]+)(ダイ)*[0-9]*チワリ.*"
convertTownArea :: Postcode -> [(String, String)]
convertTownArea p
...
| ta =~ regexJiwari
= [(replace regexJiwari ta "\\1", replace regexJiwariKana taKana "\\1")]
...
括弧内の変換
町域名に括弧が存在し、上記のビル階層などでは無い場合は括弧内文字列の変換をします。大通西(20~28丁目)
のような場合は大通西
に、留萌村(峠下)
のような場合は留萌村
と留萌村峠下
に分割します。詳細については後ほど記載します。
regexParentheses :: String
regexParentheses = "((.*))"
regexParenthesesKana :: String
regexParenthesesKana = "\\((.*)\\)"
convertTownArea :: Postcode -> [(String, String)]
convertTownArea p
...
| ta =~ regexParentheses
= [(replaceParentheses "", replaceParenthesesKana "")] ++ converAndReplace p
...
where
ta = townArea p
taKana = townAreaKana p
converAndReplace = map replaceParentheses' . convertInParentheses
replaceParentheses' (ta', taKana') =
(replaceParentheses ta', replaceParenthesesKana taKana')
replaceParentheses = replace regexParentheses ta
replaceParenthesesKana = replace regexParenthesesKana taKana
replace regex = subRegex (mkRegex regex)
括弧内文字列の変換
括弧内の文字列は、最初に、
を区切り文字として分割した上で、不要な文字列と思われるものを取り除きます。この時に重要なのは町域名の漢字(CSVの9番目)の項目をベースにするということです。町域名漢字の括弧内文字列が区切られていても、半角カナのほうはそうなっていない場合があります。その場合は半角カナの値をそのまま使うようにします。
import Data.List.Split
regexUseInParentheses :: String
regexUseInParentheses = "[0-9]+区"
regexUseInParenthesesKana :: String
regexUseInParenthesesKana = "[0-9]+ク"
regexIgnoreInParentheses :: String
regexIgnoreInParentheses =
"[0-9]|^その他$|^丁目$|^番地$|^地階・階層不明$|[0-9]*地割|成田国際空港内|次のビルを除く|^全域$"
convertInParentheses :: Postcode -> [(String, String)]
convertInParentheses p = filter shouldUse
$ zip (splitInParentheses ta) (splitInParenthesesKana taKana)
where
shouldUse :: (String, String) -> Bool
shouldUse (s, _) =
s =~ regexUseInParentheses || not (s =~ regexIgnoreInParentheses)
ta = townArea p
taKana = townAreaKana p
splitInParentheses :: String -> [String]
splitInParentheses str = case matchParentheses str of
Just strInParenthes -> splitOn "、" strInParenthes
Nothing -> []
splitInParenthesesKana :: String -> [String]
splitInParenthesesKana str =
maybe (repeat str) (splitOn "、") $ matchParenthesesKana str
matchParentheses :: String -> Maybe String
matchParentheses = matchParentheses' regexParentheses
matchParenthesesKana :: String -> Maybe String
matchParenthesesKana = matchParentheses' regexParenthesesKana
matchParentheses' :: String -> String -> Maybe String
matchParentheses' regex = fmap head . matchRegex (mkRegex regex)
その他
それ以外はCSV記載のまま出力(変換無し)です。
convertTownArea :: Postcode -> [(String, String)]
convertTownArea p
...
| otherwise
= [(ta, taKana)]
...
出力結果
細かな調整は必要と思われるものの、目に付いた大きな点は対応できた気がしてきました。動かしてみます。
main関数はこんな感じで、結果はken_all.converted.csv
ファイルに出力されます。
module Main where
import Lib
input = "KEN_ALL.CSV"
output = "ken_all.converted.csv"
main :: IO ()
main = convert input output
結果ですが、zipcloudさんのファイルとの差分は
zipcloud版行数:127,055
追加:126
削除:29
変更:77
という結果になりました。少し具体的に見てみましょう。
※記載したzipcloudさんの出力値は私の実装との比較のためにダブルクオーテーションを消してあります。zipcloudさんのデータにはKEN_ALL.CSVと同様の仕様でダブルクオーテーションが入っています。
KEN_ALL.CSVで
01214,"097 ","0970035","ホッカイドウ","ワッカナイシ","バッカイムラ(バッカイ)","北海道","稚内市","抜海村(バッカイ)",1,0,0,0,0,0
はzipcloudバージョンでは
01214,097 ,0970035,ホッカイドウ,ワッカナイシ,バッカイムラ,北海道,稚内市,抜海村,1,0,0,0,0,0
ですが、私の実装では
01214,097 ,0970035,ホッカイドウ,ワッカナイシ,バッカイムラ,北海道,稚内市,抜海村,1,0,0,0,0,0
01214,097 ,0970035,ホッカイドウ,ワッカナイシ,バッカイムラバッカイ,北海道,稚内市,抜海村バッカイ,1,0,0,0,0,0
になってしまってました。これはつまり、抜海村
とバッカイ
は同一のものを指してるので重複である、と認識する必要があったようです。
他では
- KEN_ALL.CSV
01459,"07102","0710245","ホッカイドウ","カミカワグンビエイチョウ","フクトミ(フクトミエイシン)","北海道","上川郡美瑛町","福富(福富瑛進)",1,0,0,0,0,0
- zipcloud
01459,07102,0710245,ホッカイドウ,カミカワグンビエイチョウ,フクトミ,北海道,上川郡美瑛町,福富,1,0,0,0,0,0
- my実装
01459,07102,0710245,ホッカイドウ,カミカワグンビエイチョウ,フクトミ,北海道,上川郡美瑛町,福富,1,0,0,0,0,0
01459,07102,0710245,ホッカイドウ,カミカワグンビエイチョウ,フクトミフクトミエイシン,北海道,上川郡美瑛町,福富福富瑛進,1,0,0,0,0,0
というのもありました。これも重複であることを知ってる必要がありそうです。
次はちょっと不明なもの
- KEN_ALL.CSV
03366,"02955","0295511","イワテケン","ワガグンニシワガマチ","ウエノノ39チワリ","岩手県","和賀郡西和賀町","上野々39地割",0,0,0,0,0,0
- zipcloud
03366,02955,0295511,イワテケン,ワガグンニシワガマチ,ウエノノ39チワリ,岩手県,和賀郡西和賀町,上野々39地割,0,0,0,0,0,0
- my実装
03366,02955,0295511,イワテケン,ワガグンニシワガマチ,ウエノノ,岩手県,和賀郡西和賀町,上野々,0,0,0,0,0,0
数字+地割
は削除している箇所が多い気がしますが、ここは残してあるようです。うーむ。
あと、これは結局対応ができたのですが、富山県に多いケースで
- KEN_ALL.CSV
16211,"93903","9390321","トヤマケン","イミズシ","アオイダニ","富山県","射水市","青井谷",0,0,0,1,0,0
16211,"93903","9390321","トヤマケン","イミズシ","アオイダニ(アラヤシキ)","富山県","射水市","青井谷(新屋敷)",0,0,0,1,0,0
16211,"93903","9390321","トヤマケン","イミズシ","アオイダニ(ゴカンノ)","富山県","射水市","青井谷(五官野)",0,0,0,1,0,0
16211,"93903","9390321","トヤマケン","イミズシ","アオイダニ(サンノ)","富山県","射水市","青井谷(三野)",0,0,0,1,0,0
16211,"93903","9390321","トヤマケン","イミズシ","アオイダニ(ニシタニ)","富山県","射水市","青井谷(西谷)",0,0,0,1,0,0
16211,"93903","9390321","トヤマケン","イミズシ","アオイダニ(ミズカミタニ)","富山県","射水市","青井谷(水上谷)",0,0,0,1,0,0
というのを、最初の頃
- my実装
16211,93903,9390321,トヤマケン,イミズシ,アオイダニ,富山県,射水市,青井谷,0,0,0,1,0,0
16211,93903,9390321,トヤマケン,イミズシ,アオイダニ,富山県,射水市,青井谷,0,0,0,1,0,0
16211,93903,9390321,トヤマケン,イミズシ,アオイダニアラヤシキ,富山県,射水市,青井谷新屋敷,0,0,0,1,0,0
16211,93903,9390321,トヤマケン,イミズシ,アオイダニ,富山県,射水市,青井谷,0,0,0,1,0,0
16211,93903,9390321,トヤマケン,イミズシ,アオイダニゴカンノ,富山県,射水市,青井谷五官野,0,0,0,1,0,0
16211,93903,9390321,トヤマケン,イミズシ,アオイダニ,富山県,射水市,青井谷,0,0,0,1,0,0
16211,93903,9390321,トヤマケン,イミズシ,アオイダニサンノ,富山県,射水市,青井谷三野,0,0,0,1,0,0
16211,93903,9390321,トヤマケン,イミズシ,アオイダニ,富山県,射水市,青井谷,0,0,0,1,0,0
16211,93903,9390321,トヤマケン,イミズシ,アオイダニニシタニ,富山県,射水市,青井谷西谷,0,0,0,1,0,0
16211,93903,9390321,トヤマケン,イミズシ,アオイダニ,富山県,射水市,青井谷,0,0,0,1,0,0
となってしまって、町域名が青井谷
の完全に重複したレコードが複数できてしまってました。今回これは処理時に郵便番号が変わった時点の変換後行を保持して、以降の行が同一であれば出力しない、というロジックでフィルタすることができました。ただ、KEN_ALL.CSV
は同一の郵便番号が全然離れた行に現れたりもするので、完全対応はDBなどに入れて処理するしかない感じがします。
また、今回の処理の副作用で下記のような削除行が出てしまいました。これは私の実装のほうが重複無くできているように見えますが、よく見ると末尾の方の0/1フラグが微妙に違ってます。zipcloudさんのはこの辺もちゃんと見て重複除去を行っているようです。
- KEN_ALL.CSV
16211,"93903","9390341","トヤマケン","イミズシ","サンガ","富山県","射水市","三ケ",0,1,0,1,0,0
16211,"93903","9390341","トヤマケン","イミズシ","サンガ(アタゴ)","富山県","射水市","三ケ(愛宕)",0,0,0,1,0,0
- zipcloud
16211,93903,9390341,トヤマケン,イミズシ,サンガ,富山県,射水市,三ケ,0,1,0,1,0,0
16211,93903,9390341,トヤマケン,イミズシ,サンガ,富山県,射水市,三ケ,0,0,0,1,0,0
- my実装
16211,93903,9390341,トヤマケン,イミズシ,サンガ,富山県,射水市,三ケ,0,1,0,1,0,0
ただ、よくわからないのは他の県ではこの手のは括弧内に青井谷(新屋敷、五官野、三野、西谷)
と書いてることも多いのですけど、富山県などでは何でこうなってるんでしょうか。いや、そんな疑問を持ってはいけませんね。私が悪いです。
他にも細かな相違点はありましたが、多くは重複行が残ってる部分でしたので、それが取り除かれればかなり目標近くに行けそうです。
結論
正直このやっつけ仕事で127,055行の見本ファイルとの差分が240件ぐらい、そのうち追加行が126程度ということで、結構満足してしまってるのですが、業務で実際に使うとなると厳しいでしょうか。メンテのことなども考慮すると、職人さんの作ったものを利用するか、商用のものが安心ですね。
と、ここまでやってきてふと思うのは、何で今の時代にこういうファイルが放置提供され、且つ更新し続けているのか、という点です。町域名が複数行にまたがっているということを知った時点で、このファイルそもそも大丈夫なのかな?という疑問を普通は持ってしまうでしょう。
おそらくは住所と郵便番号、管理されてきたシステムの歴史にまつわるものかと思われますが、どこかでキレイになって欲しいですね。
それはともかく、Haskell環境は本当に昔に比べてよくなりました。型シグネチャ見るために都度hackageのドキュメント見たり、慣れないEmacsの設定したりといったことは今回不要で、快適に実装ができました。ずっとHaskellからは離れてしまってたのですが、今後は積極的に使っていこうと思います。