郵便番号データのつくりかた(PHP版)

[OPENLOGI AdventCalendar](https://qiita.com/advent-calendar/2022/openlogi) 15日目担当の細川です。

だいぶ前に[同一タイトルの記事](https://qiita.com/yhosok/items/bb7fa9ff0254d20ad279)を書きましたが、様々ありましてPHP版を作る必要に迫られました。

ということで前回Haskellで実装したものをこの機会にPHPに翻訳しました。ただ、今回は作成データが使い物になるよう精度を上げる必要がありまして、以下に述べますように色々と工夫というか、ゴリ押しで頑張ってみました。ツッコミどころが色々ありますのでご意見いただけると嬉しいです。（正しい住所データが即座にダウンロードできる時代が来ますように）

作成したソースは[こちら](https://github.com/yhosok/laravel-ken-all)にあります。諸事情でlaravel上に実装してますが、書いてるソースは主に下記のファイルになります。
- [app/Models/Postcode.php](https://github.com/yhosok/laravel-ken-all/blob/main/app/Models/Postcode.php)
- [app/Services/KenAllService.php](https://github.com/yhosok/laravel-ken-all/blob/main/app/Services/KenAllService.php)

## 準備
前回と同じように、生成元データは日本郵便の公式サイトにある[読み仮名の促音・拗音を小書きで表記するもの](https://www.post.japanpost.jp/zipcode/dl/kogaki-zip.html)を使います。ソースコードではzipファイルの展開から行ってますが、この記事では前回同様 `KEN_ALL.CSV` の扱いについて主に記載します。

## 目標
今回も[zipcloudさん](http://zipcloud.ibsnet.co.jp/)が提供されてるものを目指します。前回はまぁまぁ近いところまでいくことで満足してましたが、今回は完全一致を目指します。

## つくりかた
### 基本的な流れ
[前回の記事](https://qiita.com/yhosok/items/bb7fa9ff0254d20ad279#ken_allcsv%E3%81%AE%E5%95%8F%E9%A1%8C%E7%82%B9%E3%82%92%E7%9F%A5%E3%82%8B) にもありますが、`KEN_ALL.CSV`攻略というのは要は町域列をまともに使える状態に変換することにあります。大きな流れとしてはこんな感じの実装をすることになります。

1. 町域がカッコで始まる場合、町域だけが複数行に渡ってる場合があるのでカッコが閉じるまで行を読み、町域項目をマージする
1. 町域のカッコ内は複数の住所を表していることがあるので、特定の区切り文字で分割し、その数分の住所データ行を生成する
1. 町域は住所として意味をなさない文字列が含まれる場合もあるので除去する

### 郵便番号データのクラス
今回は[こんな感じのクラス](https://github.com/yhosok/laravel-ken-all/blob/main/app/Models/Postcode.php)を作ってみました。CSVから取得した配列表現となってる行を受けて、各種項目の取得や操作を可能にしてます。面倒なので多くはマジックメソッドにしちゃってます。

### KEN_ALL変換クラス

[こういうサービスクラス](https://github.com/yhosok/laravel-ken-all/blob/main/app/Services/KenAllService.php)で。

目当てのzipからファイル取得、順次レコードを読み上げて変換後にファイル出力しています。

`convert`関数がメインの処理ですが、Haskellより随分縦長なソースになってしまい見通しが悪くなってしまいました。でもPHPなので仕方ないですね。

### 完結しない町域のマージ
ここから少し個別の処理を眺めていきたいと思います。まずはカッコに括られていて且つ町域情報として完結していない場合に、次行の値とマージする部分です。

`\Illuminate\Support\Collection::reduce` で`[<前行までの未完（カッコが閉じていない）の町域文字列>, <重複行チェック用の近い郵便番号の出力済みデータ>]`を引き回します。 配列の2番目の値については別途[後述](#出力処理)します。

該当箇所のソースはこんな感じですが、町域が`（`の文字で開始し、`）`で終わっていない場合は町域をマージした後に次行に回していきます。

```php
/** @var Postcode|null $lastUnClosedPostcode 前行で町域文字列が未完（カッコが閉じていない）場合のデータ */
/** @var Collection $lastWrittenPostcodes 重複行チェック用の近い郵便番号の出力済みデータ */
[$lastUnClosedPostcode, $lastWrittenPostcodes] = $acc;
$current = new Postcode($csvLine);
// 前行で町域文字列が完結していない場合は現在行の町域をマージする
$target = $lastUnClosedPostcode
    ? $lastUnClosedPostcode->mergeTownArea($current)
    : $current;
// マージ済みでも町域文字列が完結しない場合は次行に移動
if ($target->isUnClosedTownArea()) {
    return [$target, $lastWrittenPostcodes];
}
```

### 変換処理
ここはひたすら正規表現で当てて置換するのですが、カッコで括られてる場合は中が複数の住所を表してる場合があるので、その場合はまず分割をしてから処理します。

変換処理の基本的な流れは下記の実装になります。

```php
public function convertTownArea(): Collection
{
    $converters = [
        fn() => $this->convertIgnore(),
        fn() => $this->convertFloor(),
        fn() => $this->convertJiwari(),
        fn() => $this->convertParentheses(),
    ];
    $converted = collect($converters)
        ->reduce(function (?Collection $converted, callable $converter) {
            if ($converted) {
                return $converted;
            }
            return $converter();
        });
    return $converted ?: collect([[$this->townArea, $this->townAreaKana]]);
}
```

#### 意味のない文字列
町域として意味をなさない文字列が入っていることがあるので、その場合は町域無しにします。
ここでは `/以下に掲載がない場合|[市|町|村]の次に.*がくる場合|[市|町|村]一円|^甲、乙/u` なものは空文字に置換します。

#### ビルの階層
`中央アエル（１階）` といったものはカッコを取るなどの処理をします。ただし処理後に `地階・階層不明` となった場合は意味が無いので空文字にします。

#### 地割
[前回も記載](https://qiita.com/yhosok/items/bb7fa9ff0254d20ad279#%E5%9C%B0%E5%89%B2%E3%81%AE%E5%AF%BE%E5%BF%9C)しましたが、地割り表記をうまく変換します。正規表現初心者なのであまりこの辺うまく出来てませんが一応必要な文字列だけ抽出できるようにしてみました。

#### カッコ内の変換
町域文字列がカッコで括られており、且つ上記のビル階層ではない場合の変換です。ここが一番ややこしく、詳しくはソースコード見ていただきたいのですが、下記のような手順で処理します。

##### カッコの除去して区切り文字で分割
まずカッコ内文字列を取り出し、`、`文字で文字列を分割します。

```php
// カッコ内文字列を分割
$splitInParentheses = Str::of($this->townArea)
    ->match(self::REGEX_PARENTHESES)
    ->split('/、/');
```

##### 不要文字列の場合フィルタ
分割後のそれぞれの文字列が利用可能かどうか判定します。これは現状ひたすら個別に定義していているだけでもう少しなんとかなりそうですがとりあえず。また、これにヒットしても`大泉１区南部`のような文字列が当たる場合はそちらは不要としないで使うように実装します。

```php
// 町域のカッコ部分を除去した文字列と重複しそうだったら追加しない
if (Str::of($baseParenthesesRemoved)->length() > 1 && Str::of($townArea)->startsWith($baseParenthesesRemoved)) {
    return false;
}
// 不要な文字列と思われるものをフィルタ
return Str::of($townArea)->test(self::REGEX_USE_IN_PARENTHESES)
    || !Str::of($townArea)->test('/' . collect(self::REGEX_IGNORE_IN_PARENTHESES)->join('|') . '/u');
```

##### カッコ内の利用可能文字列ごとにレコードを作る
町域が複数の項目になる場合は、その項目ごとに出力レコードを生成します。

#### 上記以外
変換不要なのでそのまま町域文字列として使います。

### 出力処理
今回はファイルに出力します。ただし、上記の変換処理をした後に、既に出力済みのレコードと内容が重複するケースが発生します。しかもKEN_ALL.CSVはきちんと郵便番号でのソートがされておらず、同一郵便番号が少し離れた場所に登場するため前行だけ見ればよいということになりません。そのため、下記のような対応を入れます。

1. 7桁郵便番号の先頭3桁が同一な出力済み郵便番号レコード配列をreduceの引き回す値に設定する。([ここ](#完結しない町域のマージ)で記載した`[<前行までの未完（カッコが閉じていない）の町域文字列>, <重複行チェック用の近い郵便番号の出力済みデータ>]`の2番目の値)
1. この出力済みレコード配列の中に変換後データが完全一致するものが存在する場合は出力スキップ。
1. 出力後、7桁郵便番号の先頭3桁が前行と同じだった場合は出力済み郵便番号レコード配列に追加する。
1. 郵便番号先頭3桁が相違するレコードな場合は郵便番号レコード配列をリセットして時レコード情報を追加。
    - 但し、愛知県豊橋の郵便番号3桁（`440`, `441`）だけはこの単位で郵便番号が前後してしまう（ソートされてない）のでこの2つ以外だったら、という判定にする。

```php
// 愛知県豊橋
$toyohashi = ['440', '441'];
// 現在行の郵便番号が前回行と近い場合は現在行のものをマージして次へ
if (!$currentWrittenPostcode
    || (($lastWrittenPostcode
            && (
            Str::of($currentWrittenPostcode->postcode)->substr(0, 3)->is((string)Str::of($lastWrittenPostcode->postcode)->substr(0, 3))))
        // 愛知県豊橋だけは郵便番号が前後してしまうのでこういう単位で重複チェックする
        || (Str::of($currentWrittenPostcode->postcode)->substr(0, 3)->is($toyohashi) && Str::of($lastWrittenPostcode->postcode)->substr(0, 3)->is($toyohashi))
    )
) {
    return [null, $lastWrittenPostcodes->concat($targetsToWrite)];
}
```

面倒くさいですね。なんで郵便番号でソートされていないのでしょうか…。

## 出力結果
[artisanコマンドを用意してます](https://github.com/yhosok/laravel-ken-all/blob/main/app/Console/Commands/CreatePostcodeData.php)のでそちらから。無駄にJobを経由してますが本当はDBに保存したいためです。

```console
php artisan command:create-postcode-data
```
デフォルトで `storage/tmp/ken_all_converted.csv` というのができます。

```console
$ diff -u storage/tmp/ken_all_converted.csv storage/tmp/x-ken-all_utf8.csv 
$ 
```

・・・完全一致しています！！！

ちなみにzipcloudさんのはutf8じゃなかったのでそれだけテキストエディタで適当に変換したもので比較しました。

## 終わりに
これでDBに保存してapiでも作っておけば色々使えそうなのでよかったです。今後これ以上イレギュラーな住所データが追加されないことを祈っています。

今回は事業所用のは対象にしませんでしたが、後日対応試みたいと思います。

## 参考
- [郵便番号データ配信サービス zipcloud](http://zipcloud.ibsnet.co.jp/)

————————————
【オープンロジイベント情報】
<12/15(木)19:30〜>
「CTO・VPoEぶっちゃけトーク！　〜失敗から学ぶエンジニア組織論〜」
過去の失敗談をセキララに語りつつ、オープンロジでどんな組織をつくっていくかが語られる予定なので、ご都合合う方は是非ご参加ください！
https://openlogi.connpass.com/event/265230/
————————————