QR Send

スマホとPCのChrome間で、URLやテキストをQRコード1回のペアリングで送り合える自作Chrome拡張。アカウント不要・無料。

使ってみる →

住所からGeocoding APIで郵便番号を引く:建物名除去と区切り文字違いの多段フォールバック

Selenium・自動化

住所をそのままAPIに投げると、意外な確率で結果が空になる

住所リストから郵便番号を引きたいとき、一番手っ取り早いのは住所の文字列をそのままGeocoding APIに投げてしまうことです。ところが実際にやってみると、resultsが空配列で返ってくるケースが思ったより多いことに気づきます。

原因のほとんどは、住所の末尾に建物名や部屋番号、会社名などが付いていることです。「東京都〇〇区1-2-3 〇〇マンション101」のような住所は、番地まではきれいにジオコーディングできるのですが、その後ろの建物名部分が地名として解釈できずノイズになってしまいます。結果、検索全体がヒットしなくなるわけです。

1件2件ならログを見ながら手で直せば済みますが、数百件・数千件の住所リストを一括処理するバッチ処理ではそうもいきません。失敗した住所を自動で拾い上げて何とかする仕組みが必要になります。そこで作ったのが、失敗するたびに住所を段階的に加工して再検索する、多段フォールバック方式のジオコーディング処理です。

対策1: 番地より後ろの建物名らしき部分を切り落とす

一段目のフォールバックとして、住所の中の「数字の直後にある区切り」を境目とみなし、それより前だけを使って再検索してみます。

def strip_building_name(address)
  if address =~ /\d\s/
    address.match(/(^.+\d)\s/)[1]
  elsif address =~ /\d\n/
    address.match(/([\s\S]*\d)\n/)[1].gsub("\n", "")
  else
    address
  end
end

番地(数字)の直後にスペースや改行がある、という素朴なパターンマッチです。厳密に住所の文法を解析しているわけではありませんが、実務の住所データではこれだけでもかなりの割合の「建物名ノイズ」を除去できます。取りこぼしはあるものの、多段フォールバックの1段目としてはこれでいけます。

対策2: 全角スペース→半角スペース区切りの順で段階的に再試行する

建物名を除去してもなお結果が空、あるいは住所の構成要素(address_components)が取得できない場合もあります。そんなときは住所を区切り文字で分割して、前半部分だけを使うリトライをさらに重ねます。全角スペースと半角スペースが住所データの中で混在しがちなので、両方を順番に試すのがポイントです。

def geocode_with_fallback(client, address)
  candidates = [
    address,
    strip_building_name(address),
    address.split(" ").first, # 全角スペース区切り
    address.split(" ").first,  # 半角スペース区切り
  ].compact.uniq

  candidates.each do |candidate|
    result = client.geocode(candidate)
    return result if result && !result_empty?(result)
  end

  nil
end

def result_empty?(result)
  results = result["results"]
  results.nil? || results.empty? || results[0]["address_components"].nil?
end

候補を配列にまとめてuniqで重複を除いてから順番に試すようにしておくと、「建物名を除去した結果がたまたま元の住所と同じだった」というような無駄なAPI呼び出しを避けられます。API呼び出し回数はそのまま課金やレート制限に直結するので、候補の重複排除、これ、意外と効いてきます。候補の順序も「情報量が多く精度が高いものから、加工の度合いが大きく粗いものへ」という並びにしています。可能な限り正確な結果を優先しつつ、最終的には粗い候補まで試し切ることで取得率を上げる作戦です。

対策3: 郵便番号のフォーマットチェックで「それらしい誤答」も弾く

APIが結果を返してきても、住所解析の精度によっては郵便番号として不正な値が混ざることがあります(このあたりの解析精度は地域によってはまだ怪しい気がしています)。最後に正規表現でフォーマットをチェックして、通らなければ失敗扱いにします。

def extract_postalcode(result)
  postalcode = result["results"][0]["address_components"][-1]["short_name"]
  return nil unless postalcode.match?(/\d{3}-\d{4}/)

  postalcode
end

resultsが空でなければ成功、と判定を終わらせてしまうと危険です。番地を都道府県レベルまで広く解釈した結果、実際の建物とはかけ離れた郵便番号を「取得できた」と誤認することがあります。ここは注意してください。フォーマットチェックを最後に挟んでおくことで、こうした「それらしいが間違っている」結果を弾き、後続の処理に誤ったデータを流さないようにしています。郵便番号がハイフン区切りの形でちゃんと返ってくれば、ひとまず成功と見てよいでしょう。

複数データソースをまたいだ重複排除は共通クラスに切り出す

住所リストを複数のサイトから集めていると、同じ施設・同じ住所が別のデータソースに重複して登場することがあります。この判定を呼び出し側でその都度書いていると、抽出元が増えるたびに同じifがあちこちに増えてしまいます。なので独立したクラスに切り出しておきましょう。

class DuplicateChecker
  def self.duplicate?(name, address, name_history, address_history)
    name_history.include?(name) || address_history.include?(address)
  end
end

判定ロジック自体は「名前一致 or 住所一致のどちらかで重複とみなす」という単純なものです。ただ、これを1メソッドに集約しておくことが大事です。抽出元のスクリプトごとに同じ判定を書いていると、後から「住所だけでなく名前の表記ゆれも見たい」といった基準の変更が入ったときに、修正漏れが起きがちです。一部のスクリプトだけ古い基準のまま動き続ける、という不整合につながります。

呼び出し側の全体像

というわけで、これらを組み合わせると呼び出し側の処理は次のようにシンプルになります。

name_history = load_name_history
address_history = load_address_history

list.each do |name, raw_address|
  next if DuplicateChecker.duplicate?(name, raw_address, name_history, address_history)

  result = geocode_with_fallback(geocoding_client, raw_address)
  next if result.nil?

  postalcode = extract_postalcode(result)
  next if postalcode.nil?

  save_record(name, raw_address, postalcode)
end

重複排除・多段フォールバック・フォーマットチェックという3つの関心事が、それぞれ独立した関数・クラスに分かれています。おかげで呼び出し側は「重複ならスキップ、ジオコーディングに失敗したらスキップ、フォーマットが不正ならスキップ」という条件を並べるだけで済みます。なお、APIキーはコード中に埋め込まず、環境変数から読み込むようにしています。

api_key = ENV.fetch("GEOCODING_API_KEY")

ただ正直に言うと、この多段フォールバックも万能ではありません。番地そのものが省略されていたり、住所の表記がかなり崩れているケースでは、最後まで候補を試しても空振りに終わることがあります。そういう住所は無理に取りに行かず、nilのままスキップして手動確認に回す、という割り切りで運用しています。

同じように住所データの前処理で悩んでいる方がいたら、この多段フォールバックの考え方を参考にしてもらえると嬉しいです。

コメント

タイトルとURLをコピーしました