Ruby
正規表現
SQLite3
住所

【Ruby】住所を検出・分割するAPIが欲しいので作ってみた

概要

 住所絡みのAPIにおいて、「住所から緯度・経度を割り出すAPI」とか「郵便番号から住所を割り出すAPI」とかはよくありますが、「文字列から住所を切り出すAPI」は案外ないものですね……。
 例えば「住所:〒105-0011 東京都港区芝公園4丁目2-8東京タワー大展望台2F TEL:03-3433-5111」を「["東京都","港区","芝公園","4-2-8","東京タワー特別展望台2階"]」と変換するぐらいパワフルな住所APIが欲しかったので、Rubyで実際に作成してみました。

※免責事項:以下のコードは、あらゆる住所を正確に検出・分割できることを保証しません。
※完成品だけ欲しい場合はここに置いておきます(MITライセンスとします)。
  住所分割用ライブラリ

Step0:必要なデータを用意する

 住所を検出する際、一番頼りになるのは公式が出す郵便番号データです。
   郵便番号データダウンロード - 日本郵便
 ただ、この郵便番号データには色々と問題があり、

  • 列数がちょっと多すぎる(フラグ項目が6列もある)
  • 1行にするべきデータが分割されている
  • 「以下に掲載がない場合」「○○市一円」などの冗長な情報が記されている

といった点があることから、実際には第三者が加工したものを用いるのがベターとされています。
  郵便番号データは自分で加工しない - daily dayflower
  郵便番号から住所を検索するサービスにまともなものがない - ぐるぐる~
  郵便番号データのダウンロード - zipcloud
 ここで、プログラミング言語からの扱いやすさを考慮した結果、私はSQLite形式でアップロードされているデータを採用することにしました。このサイトにはMySQLなどのDB形式もあるのですが、rubyならgem 'sqlite3'すればすぐ使えるので便利……というのが採用理由です。
  住所データSQL【住所.jp】

Step1:余計な文字列を削除する

 住所を検出するより先に、余計な文字列を削除してしまいましょう。つまり、電話番号とか郵便番号とか括弧に覆われた文字列などを置換テクニックで処理してしまうわけです。こうすることで、後の文字列処理がしやすくなります。

# input_addressを入力とする

# NKFC正規化しておく
address = UNF::Normalizer.normalize(input_address, :nfkc)
# 電話番号と思わしき文字列を削除
address.gsub!(/[\d\(\)-]{9,}/, '')
address.gsub!(/TEL:|FAX:|TEL|FAX/, '')
# 郵便番号と思わしき文字列を削除
address.gsub!(/\d\d\d-\d\d\d\d/, '')
address.gsub!(/〒|郵便番号|郵便/, '')
# 括弧に囲われた部分を削除
address.gsub!(/【.*?】/, '')
address.gsub!(/≪.*?≫/, '')
address.gsub!(/《.*?》/, '')
address.gsub!(/◎.*?◎/, '')
address.gsub!(/〔.*?〕/, '')
address.gsub!(/\[.*?\]/, '')
address.gsub!(/<.*?>/, '')
address.gsub!(/\(.*?\)/, '')
address.gsub!(/「.*?」/, '')
# 特定フレーズの後にある文字を削除
address.gsub!(/(◎|※|☆|★|◇|◆|□|■|●|○|~|〜).*/, '')

Step2:都道府県を検出する

 主に2種類の方法が考えられます。

  • Step0のDBから都道府県一覧を取り出して照合する
  • ざっくり正規表現でマッチングする
# DBを読み込む
require "sqlite3"
db = SQLite3::Database.new 'zenkoku.sqlite3'
# 都道府県一覧を取り出す(Enumerable#mapで1列目だけ取り出しているのがポイント)
sql = "SELECT ken_name from ad_address GROUP BY ken_name"
pref_list = db.execute(sql).map{|p| p[0]} # ["三重県", "京都府", ..., "鹿児島県"]
# 分析開始(addressが取り出される対象で、norm_address[:pref]が検出した都道府県名)
pref_list.each{|pref|
    # 取り出した都道府県名にマッチしない場合は飛ばす
    next unless address.include?(pref)
    # マッチする場合は、「マッチ箇所以降の文字列」を取り出す
    norm_address[:pref] = pref
    address = address.match(/#{norm_address[:pref]}(.*$)/)[1]
    break
}

# DBを読み込まない場合のコード
# 当然「東京県」などの出鱈目な住所にもマッチしてしまうので注意
regex = /[^\x00-\x7F]{2,3}県|..府|東京都|北海道/
if address =~ regex
    norm_address[:pref] = address.match(regex)[0]
    address = address.match(/#{norm_address[:pref]}(.*$)/)[1]
end

Step3:市区を検出する

 Step2で都道府県が判明している場合は、それをキーとしてDB検索することにより、市区一覧を取り出すことができます。
 そこで、どれにマッチするかを順に試すことにより、市区情報を検出可能となります。

if norm_address[:pref] != ""
    # 都道府県が判明している場合、市区一覧を取り出し、そこからマッチングを行う
    sql = "SELECT city_name FROM ad_address WHERE ken_name='#{norm_address[:pref]}' GROUP BY city_name"
    city_list = db.execute(sql).map{|p| p[0]}
    # 市区を検出する
    # (既存の市区にマッチさせる)
    match_index = address.size # norm_address[:city]がマッチした位置
    city_list.each{|city|
        # cityがマッチした位置
        match_index_temp = address.index(city)
        # cityがマッチした際、
        # A. match_indexよりも手前の位置にマッチした場合
        # B. match_indexと同じ位置にマッチし、cityがnorm_address[:city]より長い場合
        # のどちらかなら、norm_address[:city]とmatch_indexを更新する
        if !match_index_temp.nil?
            if match_index_temp < match_index
                norm_address[:city] = city
                match_index = match_index_temp
            end
            if match_index_temp == match_index && city.size > norm_address[:city].size
                norm_address[:city] = city
                match_index = match_index_temp
            end
        end
    }
    address = address.match(/#{norm_address[:city]}(.*$)/)[1]
end

 また、Step2で都道府県が判明していない場合、上記コードからWHERE ken_name='#{norm_address[:pref]}'を抜いた上で検索を行うこともできますが、当然その分だけ計算コストは増大します。
(例:2018/01/18現在、「東京都」で絞り込むと市区は62種類しかないが、絞り込まないと1933種類から検索を行うことになる)
 それを嫌って単なる正規表現で処理する場合、例えばこういったコードを書くことになるでしょう。
  参考:短い市区町村名・長い市区町村名

# マッチさせるパターンを配列で用意する
regex_pattern = []
regex_pattern.push(/([^\x00-\x7F]{1,6}市[^\x00-\x7F]{1,4}区)/)
regex_pattern.push(/([^\x00-\x7F]{1,3}郡[^\x00-\x7F]{1,5}町)/)
regex_pattern.push(/(四日|廿日|野々)市市/)
regex_pattern.push(/([^\x00-\x7F市]{1,6}市)/)
regex_pattern.push(/([^\x00-\x7F]{1,4}区)/)
# 順に試していき、マッチしたものを判定結果とする
regex_pattern.each{|pattern|
    # マッチしなければ飛ばす
    next unless address =~ pattern
    norm_address[:city] = address.match(pattern)[0]
    address = address.match(/#{norm_address[:city]}(.*$)/)[1]
    break
}

Step4:町村を検出する

 ここを無視して先に「Step5:番地を検出する」で番地を検出した後に町村を割り出す(町村は番地より手前側にあるため)こともできますが、判定精度を考えると先に判定しておいた方が無難でしょう。
 ここを正規表現で考えようとするとマッチングするパターンの量が大変なことになりそうですので、SQLの結果から絞り込む作戦でいいかと思われます(方法は「Step3:市区を検出する」と同様なので略)。

Step5:番地を検出する

 まず、「番地」がどういった文字列かについて考えてみましょう。
「数字から始まる」ことに異論の余地はないと思いますが、ここで言う数字とは漢数字も指すことに注意が必要です。流石に「壱弐参」などといった旧字体は出てこないと思われますが、それでも「一二三四五六七八九十百」ぐらいまでは考慮が必要になるでしょう。
 また、数字と数字の間を繋ぐ文字列としては、正規表現で表現すると「/丁目|丁|番地|番|号|-|‐|ー|−|の|東|西|南|北/」ぐらいの候補があると思われます。これらの情報から、番地にマッチすると思われる正規表現は次のように表現されます。

#漢数字
k_num = '[一二三四五六七八九十百千万]'
#繋ぎ文字1:数字と数字の間(末尾以外)
s_str1 = '(丁目|丁|番地|番|号|-|‐|ー|−|の|東|西|南|北)'
#繋ぎ文字2:数字と数字の間(末尾)
s_str2 = '(丁目|丁|番地|番|号)'
#全ての数字
all_num = "(\\d+|#{k_num}+)"
#「先頭は数字、途中は数字か繋ぎ文字1、最後は数字か繋ぎ文字2」を満たす正規表現
regex6 = /#{all_num}*(#{all_num}|#{s_str1}{1,2})*(#{all_num}|#{s_str2}{1,2})/

後は、それに正規表現でマッチングさせるだけで、番地情報を取り出すことができます。

# マッチングを行う
if address =~ regex_pattern
    norm_address[:addr1] = address.match(regex_pattern)[0]
    address = address.match(/#{norm_address[:addr1]}(.*$)/)[1]
end

 ……ただ、この方式の場合、

  • 「六万寺町」「百人町」など、数字入りの町名である
  • Step4で町名を取り除いていない

の両条件を満たすと町名の一部を番地と誤認してしまいます。Rubyの正規表現は、たとえ最長マッチングでも手前の位置にヒットした方が優先されるため、対策として次のようなコードを書くことになります。
(※Step4をちゃんと実施していれば不要な苦労だと思われます)

if address =~ regex_pattern
    # 番地候補を初期化
    addr1_list = []
    # 番地候補を追加していく
    temp_address = address.clone #sub!メソッドで消していくので生贄を立てる
    while temp_address =~ regex_pattern
        addr1 = temp_address.match(regex_pattern)[0]
        addr1_list.push(addr1)
        temp_address.sub!(addr1, "")
    end
    # 最も長い番地候補が正しい番地だと思われる
    norm_address[:addr1] = addr1_list.max{|a, b| a.size <=> b.size}
    norm_address[:town] = address[0, address.index(norm_address[:addr1])] #ついでに町村も推定
    address = address.match(/#{norm_address[:addr1]}(.*$)/)[1]
end

 なお、こうして取り出された番地は、「丁目」や「ー」などの英数字記号以外の文字列を含むことになります。全て「4-2-8」などの英数字記号な形に正規化したい場合のロジックも貼っておきます。

# 番地を正規化する
def norm_addr1(addr1)
    addr1_temp = addr1.clone
    # ハイフン以外のハイフンっぽい記号を置き換える
    addr1_temp.gsub!(/-|‐|ー|−/, '-')
    # 「丁目」などをハイフンに置き換える
    addr1_temp.gsub!(/丁目|丁|番地|番|号|の/, '-')
    addr1_temp.gsub!(/-{2,}/, '-')
    addr1_temp.gsub!(/(^-)|(-$)/, '')
    # 漢数字をアラビア数字に置き換える
    pattern = /[一二三四五六七八九十百千万]+/
    while addr1_temp =~ pattern
        match_string = addr1_temp.match(pattern)[0]
        arabia_number_string = "#{kan_to_arabia(match_string)}"
        addr1_temp.sub!(match_string, arabia_number_string)
    end
    return addr1_temp
end

# 漢数字をアラビア数字に変換する
# 実は「十一万」以上の文字列で変換ミスが発生するが、
# 番地変換でそこまで大きな数を考慮することはないと思われる
def kan_to_arabia(str)
    # 変換するためのハッシュ
    hash = {
        "一" => 1, "二" => 2, "三" => 3, "四" => 4, "五" => 5, 
        "六" => 6, "七" => 7, "八" => 8, "九" => 9, "〇" => 0, 
        "十" => 10, "百" => 100, "千" => 1000, "万" => 10000
    }
    # 漢数字を数字に置き換える
    num_array = str.chars.to_a.map{|c| hash[c]}
    # 10未満の数字を横方向に繋げる
    # 例:[1,9,4,5]→[1945]
    num_array2 = []
    temp = 0
    num_array.each{|num|
        if num < 10
            temp *= 10
            temp += num
        else
            if temp != 0
                num_array2.push(temp)
            else
                num_array2.push(1)
            end
            num_array2.push(num)
            temp = 0
        end
    }
    num_array2.push(temp)
    # 10・100・1000・10000の直前にある数字とで積和する
    # 例:[2,100,5,10,3]→253
    val = 0
    0.upto(num_array2.size / 2 - 1).each{|i|
        val += num_array2[i * 2] * num_array2[i * 2 + 1]
    }
    val += num_array2.last
    return val
end

Step6:建物名などを検出する

 住所だけならStep5までで取得できますが、ここまで来ると建物名も取得したくなると思います。
 番地までを取り去った文字列を「建物名」として終了……でも構いませんが、入力データというのはしばしば、

  • 余計な空白文字が含まれる(番地までと建物名との間に空白を入れるなど)
  • 複数の住所を並べてしまっている(余分な住所を削らないと「建物名」がとんでもないことに)
  • 電話番号やメールアドレスなど余計な文字列が末尾に書かれていることすら

だったりしますので、正規表現で取り除いてしまいましょう。

# 建物名を正規化する
def norm_addr2(addr2)
    addr2_temp = addr2.clone
    # 括弧等は排除し、「○F」は「○階」と置き換える
    addr2_temp = addr2_temp.gsub(/\(.*/,'').gsub(/(\d+)F/, '\1階')
    # 「○階」「○号室」を含む場合、そこまでしか読み取らない
    addr2_temp = addr2_temp.match(/^.*号室/)[0] if addr2_temp.include?('号室')
    addr2_temp = addr2_temp.match(/^.*階/)[0] if addr2_temp.include?('階')
    # 別の住所を含んでいる場合、その部分だけ削除する
    pref_list().each{|pref|
        if addr2_temp.include?(pref)
            addr2_temp = addr2_temp[0, addr2_temp.index(pref)]
            break
        end
    }
    # 先頭・末尾の空白を削除する
    addr2_temp = addr2_temp.sub(/^ +/, '').sub(/ +$/, '')
    return addr2_temp
end