26
18

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

OPENLOGIAdvent Calendar 2018

Day 20

郵便番号データのつくりかた

Last updated at Posted at 2018-12-19

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)した上でFromRecordToRecordのinstance宣言だけすれば、基本的な型の値については変換の実装不要になります。もちろん個別実装も可能です。また、今回は使いませんでしたが、FromNamedRecordToNamedRecordのinstance実装することで、ヘッダーの名称からencode & decodeすることもできるようです。

今回は適当なフィールド名で全て取り込むことにします。

Lib.hs
{-# 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結果の型は(町名域文字列の括弧が閉じてない前のデータ, 郵便番号変更あった行のデータ)で、出力データの判定に使用します。

Lib.hs
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-tdfaregex-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からは離れてしまってたのですが、今後は積極的に使っていこうと思います。

参考

26
18
0

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
26
18

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?