Webサイト内の多数の住所の地点をGoogleマップのマイマップに配置する

## 概要
[多数の店舗名と住所が記載されたWebサイト](https://www.yugioh-card.com/japan/tacticaltry/ "遊戯王OCG TACTICAL-TRY DECK 特設サイト")があり、分かりづらかった。[Googleマップ](https://www.google.com/maps)のマイマップにそれぞれの場所を配置できれば一目でわかるようになるかなと思い、そのようにした。

この記事では Windows 11 にインストールした Python 3.12 を使用しています。

## 手法
### Webサイト内の住所の取得
Pythonの外部モジュール [requests](https://pypi.org/project/requests/) によってWebページ内の情報を簡単に取得できる。
`html.parser.HTMLParser`の`handle_data()`で、HTML内の各データ部分ごとに処理を行える。

そこから住所のみを取得するのはちゃんとやると複雑だが、今回対象としたWebサイトでは「47都道府県のどれかで始まる7文字以上の文字列」を条件とすればよさそうだったのでそのようにした。

また、住所の1つ前の位置に地点名が記載されていたため、それも併せて取得した。

```python:Webサイト内の住所の取得
import requests
from html.parser import HTMLParser

OFFSET = -1
TARGET_URL = 'https://target-url'
PREFECTURES = '北海道,青森県,岩手県,宮城県,秋田県,山形県,福島県,茨城県,栃木県,群馬県,埼玉県,\
千葉県,東京都,神奈川県,新潟県,富山県,石川県,福井県,山梨県,長野県,岐阜県,静岡県,愛知県,三重県,\
滋賀県,京都府,大阪府,兵庫県,奈良県,和歌山県,鳥取県,島根県,岡山県,広島県,山口県,徳島県,香川県,\
愛媛県,高知県,福岡県,佐賀県,長崎県,熊本県,大分県,宮崎県,鹿児島県,沖縄県'.split(',')
MIN_ADDRESS_LENGTH = 7

class MyHTMLParser(HTMLParser):
    pos = 0
    data = []
    labels = []
    addresses = []
    def handle_data(self, data):
        data = data.rstrip()
        self.data.append(data)
        if MIN_ADDRESS_LENGTH <= len(data) and any(data.startswith(pref) for pref in PREFECTURES):
            self.addresses.append(data)
            self.labels.append(self.data[self.pos + OFFSET])
        self.pos += 1

text = requests.get(TARGET_URL).text
parser = MyHTMLParser()
parser.feed(text)
```

`HTMLParser`を継承した自作クラス`MyHTMLParser`を使って上記のようにすると、リスト`parser.addresses`に住所、リスト`parser.labels`に地点名が順に入った。

### マイマップにインポートするデータの出力
#### 方法1：住所リストをそのまま用いる

Googleマップのマイマップにデータをインポートする場合、緯度/経度の座標情報のほか、「住所」「場所の名前」といった情報からでもインポートできる。
[Google マイマップ ヘルプ：地図上の対象物をファイルからインポートする](https://support.google.com/mymaps/answer/3024836?hl=ja&ref_topic=3024924&sjid=4346445579176276697-AP)

これに頼ることにして、前項での住所と地点名のリストをそのままcsvファイルに出力した。一度にインポートできる地点は2000箇所までの制限があるようなので2000行ごとに分割して出力し、以下のようにした。

```python:マイマップにインポートするデータの出力：方法1
import csv

MAX_OUTPUT = 2000

for cycle in range((len(parser.addresses) + MAX_OUTPUT - 1) // MAX_OUTPUT):
    lines = []
    lines.append(['名称', '住所'])
    if (cycle + 1) * MAX_OUTPUT <= len(parser.addresses):
        output_length = MAX_OUTPUT
    else:
        output_length = len(parser.addresses) % MAX_OUTPUT
    for i in range(output_length):
        label = parser.labels[MAX_OUTPUT * cycle + i]
        address = parser.addresses[MAX_OUTPUT * cycle + i]
        lines.append([label, address])
    with open(f'layer_{cycle + 1}.csv', 'w', encoding='utf-8', newline='') as f_csv:
        csv.writer(f_csv).writerows(lines)
```

これで出力されたcsvファイルを、Googleマップのマイマップに新しく作成した地図にインポートする。「目印を配置する列の選択」で住所、「マーカーのタイトルとして使用する列」で名称の列を選択する。

![エラーメッセージ「地図上に表示できなかった行が366行あります。データ表に赤色で示されたエラーを修正してください。」](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3811568/464105a0-31a9-0f7a-31f7-5e0d02fc0f55.png)

あまりにもエラーが多い。いろいろ試してみて、以下のようにするとエラーが減った。
- 一度Googleドライブに入れてそこから取り込むとエラーが解消された事例があるようなので、そのようにするとエラーが減った。
- 1つのcsvファイルに入れる行数を減らして取り込むとなぜかエラーが減った。

上記では限界があったため、さらに以下のように修正すると一部はエラーが解消された。
- 都道府県の直後に半角スペースが入っているのを削除するとエラーが解消された。
- 建物名の後に階数が入っているのを削除するとエラーが解消された。
- 住所の[大字](https://ja.wikipedia.org/wiki/%E5%A4%A7%E5%AD%97)の後に入っている「字」の文字を削除するとエラーが解消された。

しかしどうにもエラーが解消しきれないものは残ってしまった。

#### 方法2：Google Maps API を利用して住所を座標情報に変換する
[Google Maps API の Geocoding API](https://developers.google.com/maps/documentation/geocoding?hl=ja) を使うことで、住所を緯度/経度の座標情報に変換することができる。有料ではあるがアカウントごとに毎月無料クレジットが付与されるほか、初回登録時に追加で期間限定の無料クレジットが付与される。また、能動的に有料プランに移行しない限りは課金されることはない。

↓無料プランだとこんなのが出る
![無料トライアルのステータス: ¥47,201.00 クレジット、残り 88 日。フルアカウントを有効にすると、Google Cloud のすべての機能に無制限でアクセスできます。残りのクレジットは利用分に対してのみ課金されます。](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3811568/db8d8eb8-39bd-4dc8-44af-83462ebc07c2.png)

この投稿の時点（2024年6月9日）では、Geocoding API のリクエスト10万回までは1回につき0.005米ドル、無料クレジットは毎月200米ドルなので、Google Maps API の他の機能を使わなければ毎月4万回までは無料で使える計算になる。

ドキュメントに記載の通りに Google Cloud のセットアップやAPIキーの取得を行う。

Geocoding API にリクエストを送るのも requests で行える。取得したAPIキーをコード内に記載して、以下のようにした。

```python:マイマップにインポートするデータの出力：方法2
import requests
import urllib.parse
import json
import csv

KEY = 'geocoding-api-key'
MAX_OUTPUT = 2000

def address2point(address):
    params = urllib.parse.urlencode({'address': address, 'key': KEY})
    url = f'https://maps.googleapis.com/maps/api/geocode/json?{params}'
    res = requests.get(url)
    obj = json.loads(res.text)
    try:
        point = obj['results'][0]['geometry']['location']
    except:
        return (None, None)
    return (point['lng'], point['lat'])

for cycle in range((len(parser.addresses) + MAX_OUTPUT - 1) // MAX_OUTPUT):
    lines = []
    lines.append(['名称', '住所', '経度', '緯度'])
    if (cycle + 1) * MAX_OUTPUT <= len(parser.addresses):
        output_length = MAX_OUTPUT
    else:
        output_length = len(parser.addresses) % MAX_OUTPUT
    for i in range(output_length):
        label = parser.labels[MAX_OUTPUT * cycle + i]
        address = parser.addresses[MAX_OUTPUT * cycle + i]
        longtitude, latitude = parser.points[MAX_OUTPUT * cycle + i]
        lines.append([label, address, longtitude, latitude])
    with open(f'layer_{cycle + 1}.csv', 'w', encoding='utf-8', newline='') as f_csv:
        csv.writer(f_csv).writerows(lines)
```

なんだか実行時間が長かったが、これで出力されたcsvファイルを同様にインポートする。「目印を配置する列の選択」では、緯度/経度のそれぞれについて、各列が緯度/経度のどちらに対応するかを選択する必要がある。

![目印を配置する列の選択画面](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3811568/80e68439-8054-f256-2093-0da30db13563.png)

問題なく取り込めた。

![問題なく取り込めたマイマップ](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3811568/0c0bf04a-15a2-0372-91c8-40be60dfa0c3.png)

## コード全体
```python:方法1：住所リストをそのまま用いる
import requests
from html.parser import HTMLParser
import csv

OFFSET = -1
TARGET_URL = 'https://target-url'
MAX_OUTPUT = 2000
PREFECTURES = '北海道,青森県,岩手県,宮城県,秋田県,山形県,福島県,茨城県,栃木県,群馬県,埼玉県,\
千葉県,東京都,神奈川県,新潟県,富山県,石川県,福井県,山梨県,長野県,岐阜県,静岡県,愛知県,三重県,\
滋賀県,京都府,大阪府,兵庫県,奈良県,和歌山県,鳥取県,島根県,岡山県,広島県,山口県,徳島県,香川県,\
愛媛県,高知県,福岡県,佐賀県,長崎県,熊本県,大分県,宮崎県,鹿児島県,沖縄県'.split(',')
MIN_ADDRESS_LENGTH = 7

class MyHTMLParser(HTMLParser):
    pos = 0
    data = []
    labels = []
    addresses = []
    def handle_data(self, data):
        data = data.rstrip()
        self.data.append(data)
        data = data.replace(' ', '')
        if MIN_ADDRESS_LENGTH <= len(data) and any(data.startswith(pref) for pref in PREFECTURES):
            self.addresses.append(data)
            self.labels.append(self.data[self.pos + OFFSET])
        self.pos += 1

text = requests.get(TARGET_URL).text
parser = MyHTMLParser()
parser.feed(text)

for cycle in range((len(parser.addresses) + MAX_OUTPUT - 1) // MAX_OUTPUT):
    lines = []
    lines.append(['名称', '住所'])
    if (cycle + 1) * MAX_OUTPUT <= len(parser.addresses):
        output_length = MAX_OUTPUT
    else:
        output_length = len(parser.addresses) % MAX_OUTPUT
    for i in range(output_length):
        label = parser.labels[MAX_OUTPUT * cycle + i]
        address = parser.addresses[MAX_OUTPUT * cycle + i]
        lines.append([label, address])
    with open(f'layer_{cycle + 1}.csv', 'w', encoding='utf-8', newline='') as f_csv:
        csv.writer(f_csv).writerows(lines)
```

```python:方法2：Google Maps API を利用して住所を座標情報に変換する
import requests
import urllib.parse
import json
from html.parser import HTMLParser
import csv

OFFSET = -1
KEY = 'geocoding-api-key'
TARGET_URL = 'https://target-url'
MAX_OUTPUT = 2000
PREFECTURES = '北海道,青森県,岩手県,宮城県,秋田県,山形県,福島県,茨城県,栃木県,群馬県,埼玉県,\
千葉県,東京都,神奈川県,新潟県,富山県,石川県,福井県,山梨県,長野県,岐阜県,静岡県,愛知県,三重県,\
滋賀県,京都府,大阪府,兵庫県,奈良県,和歌山県,鳥取県,島根県,岡山県,広島県,山口県,徳島県,香川県,\
愛媛県,高知県,福岡県,佐賀県,長崎県,熊本県,大分県,宮崎県,鹿児島県,沖縄県'.split(',')
MIN_ADDRESS_LENGTH = 7

def address2point(address):
    params = urllib.parse.urlencode({'address': address, 'key': KEY})
    url = f'https://maps.googleapis.com/maps/api/geocode/json?{params}'
    res = requests.get(url)
    obj = json.loads(res.text)
    try:
        point = obj['results'][0]['geometry']['location']
    except:
        return (None, None)
    return (point['lng'], point['lat'])

class MyHTMLParser(HTMLParser):
    pos = 0
    data = []
    labels = []
    addresses = []
    points = []
    def handle_data(self, data):
        data = data.rstrip()
        self.data.append(data)
        if MIN_ADDRESS_LENGTH <= len(data) and any(data.startswith(pref) for pref in PREFECTURES):
            self.addresses.append(data)
            self.points.append(address2point(data))
            self.labels.append(self.data[self.pos + OFFSET])
        self.pos += 1

text = requests.get(TARGET_URL).text
parser = MyHTMLParser()
parser.feed(text)

for cycle in range((len(parser.addresses) + MAX_OUTPUT - 1) // MAX_OUTPUT):
    lines = []
    lines.append(['名称', '住所', '経度', '緯度'])
    if (cycle + 1) * MAX_OUTPUT <= len(parser.addresses):
        output_length = MAX_OUTPUT
    else:
        output_length = len(parser.addresses) % MAX_OUTPUT
    for i in range(output_length):
        label = parser.labels[MAX_OUTPUT * cycle + i]
        address = parser.addresses[MAX_OUTPUT * cycle + i]
        longtitude, latitude = parser.points[MAX_OUTPUT * cycle + i]
        lines.append([label, address, longtitude, latitude])
    with open(f'layer_{cycle + 1}.csv', 'w', encoding='utf-8', newline='') as f_csv:
        csv.writer(f_csv).writerows(lines)
```

## まとめ

Webサイトからの住所の取得と、Googleマップのマイマップに配置するためのcsvファイルの出力をPythonで行い、出力されたcsvファイルをマイマップにインポートした。
- 問題なくインポートできるか、または数件の修正で済むのであれば、取得した住所や場所の名前をそのままcsvファイルに出力するのが簡単でよさそう
- Google Maps API の Geocoding API を使うことで、住所を緯度/経度の座標情報に変換することができる