GoogleMapsから検索できたらピン立てしてレーティングとクチコミを取得して表示するメモ2025

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/94562/5266c42e-78ac-c23f-efcc-6c36741c5410.png)

:::note info
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/94562/ed23f3fc-335f-884c-7d99-3d1f1c2a1b2e.png)

4年越しの自分のソース全改修はシビれたぜ！！:joy:
https://github.com/duri0214/portfolio/pull/188

gptにコミットコメント書かせて、蓄積したコミットコメントをgptでまとめてみた。なかなかいいね
:::

:::note warn
クチコミは絵文字の問題でdbに登録できないので作るのを省略した :sob:
https://github.com/duri0214/portfolio/issues/189
:::


## Google Cloud Platform のmaps系機能をONにする
事前準備
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/94562/931d23cb-29f4-750c-280a-818f185b2c39.png)

https://console.cloud.google.com/google/maps-apis/api-list?inv=1&invt=AblKFw&project=probable-goods-398600

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/94562/d8d8f2fa-a838-ee3d-7e65-8234e78599c7.png)
※ウェブサイトの制限がONだと403になってしまうところに課題がある（できれば制約をつけたい）。
※Directions API はportfolioの別のアプリケーションで使用している

:::note warn
[fieldmask](https://developers.google.com/maps/documentation/places/web-service/nearby-search?hl=ja#fieldmask)

まぁだいたいこのあたりを使うんじゃないかな？
- places.adrFormatAddress
- places.displayName
- places.formattedAddress
- places.googleMapsUri
- places.id
- places.location
- places.reviews
:::

## 概要
- [任意のプレイスリスト（例えば水道事業者一覧）](http://www.pref.kanagawa.jp/cnt/f1029/p70915-1.html) をスクレイピング
- 「神奈川県内広域水道企業団」とかそういうワードから緯度経度の配列を取得
- GoogleMapにロケーションアイコンを刺す

というのを当初は目指していたらしいが、どれもやればすぐ実装できるので今回はとにかく的を絞ってDDDリファクタリングに注力した（かなりゴリ押しのソースだったな。。当時必死だったんだろう）。そして、ただ難しいだけの機能もガッツリ削った。
![image](https://qiita-image-store.s3.amazonaws.com/0/94562/2057bdf2-17e8-53b9-8ba8-2c795c3fa212.png)

## Tips
:::note warn
Django アプリケーションで JSON ファイルを出力して利用しようとした際に、パスの解釈の違いによって404エラーが発生した問題について
※この機能は実装から削ったが、当時起こったトラブルシュートとして残しておく

### 問題の背景
1. Python (Django) での JSON ファイルの出力
Django の view.py ファイルで、JSON データを生成し、それをローカルの静的ファイルとして保存しました。

```py
# view.py
with open('googlemaps/static/googlemaps/js/data.json', 'w') as file:
    json.dump(data, file)
```
このコードは、Django プロジェクトのディレクトリ構造に基づき、googlemaps/static/googlemaps/js/data.json というパスに JSON ファイルを保存しています。

2. HTML/JavaScript からの JSON ファイル読み込み
HTML 内の JavaScript では、以下のように jQuery を使用して JSON ファイルを読み込もうとしています。
```js
$.getJSON("/static/googlemaps/js/data.json", function(data) {
    console.log(data);
});
```
:::

:::note warn
JSONが読めない！？Ajax の非同期処理についての理解不足から起きたトラブルとその解決について

### 問題の背景
1. JSON 読み込みの試み
JavaScript の getJSON を使って、サーバーから JSON データを取得しようとしました。

```js
let dataArray = [];
$.getJSON("/static/googlemaps/js/data.json", function(data) {
    dataArray = data;
});
console.log(dataArray); // ここで値を確認しようとした
```

期待した動作
- $.getJSON でデータを取得して dataArray に代入する
- console.log(dataArray) で内容を確認する

実際の動作
- console.log(dataArray) を実行した時点で dataArray は空のまま
- 理由は、Ajax は非同期で動作するため、$.getJSON の処理が完了する前に console.log が実行されてしまうから

### 非同期処理とは？
非同期処理とは、特定の処理が完了するのを待たずに、次のコードが実行される仕組みを指します。

- 同期処理の場合
一つの処理が完了するまで次の処理は実行されません。ローカルアプリケーションではこの動作が一般的です。
例: ファイルを読み込む → データを加工する → 加工結果を出力する。

- 非同期処理の場合
処理を開始すると、その完了を待たずに次のコードが実行されます。処理が完了したら、指定されたコールバック関数（function(data) の部分）が実行されます。
例: Ajax リクエストを送信 → 他のコードを実行 → レスポンスを受信したらデータを処理

### 解決策
非同期処理の性質を理解し、適切にコールバックや Promise を使うことで解決できます
1. コールバック関数を使用する
データを取得した後に行いたい処理を、コールバック関数内に記述します。
```js
$.getJSON("/static/googlemaps/js/data.json", function(data) {
    console.log(data); // データが取得されたタイミングで実行される
});
```

2. Promise（または async/await）を使用する
Promise を使うと非同期処理をより直感的に扱えます。
```js
// Promise を使う例
$.getJSON("/static/googlemaps/js/data.json")
    .done(function(data) {
        console.log(data); // データが取得されたら実行
    });

// async/await を使う例
async function fetchData() {
    const response = await fetch("/static/googlemaps/js/data.json");
    const data = await response.json();
    console.log(data); // データが取得されたら実行
}
fetchData();
```
### 非同期処理が慣れない理由
- ローカルアプリケーションでは、同期処理が一般的で、「まだデータがない」という状況がほぼ発生しません
- 一方、Web アプリケーションでは、リクエストやレスポンスに時間がかかることを前提とした非同期処理が一般的です
非同期処理では、「処理の完了を待たずに次に進む」 という動作を理解し、それに合わせてコードを書く必要があります。
:::

## Source
### domain/repository/googlemaps.py
```py
from django.db.models import QuerySet

from gmarker.domain.valueobject.googlemaps import PlaceVO
from gmarker.models import NearbyPlace


class NearbyPlaceRepository:
    """
    近隣場所情報を管理するリポジトリクラスです。
    カテゴリーフィールドは以下の値を持ちます:
        1 = "Category": 具体的なカテゴリで検索され、登録される場所
        2 = "Pin Select": Googleマップ上でピンを選び、登録した場所
        3 = "Database Insert": データベースから直接挿入される情報
        9 = "Default Location": デフォルトの場所（マップを初期表示したときの中心）
    ここで、"Category"と"Pin Select"は主にユーザーが画面から登録し、"Database Insert"は主にメンテナンス時に使用されます。
    """

    CATEGORY_SEARCH = 1
    PIN_SELECT = 2
    DATABASE_INSERT = 3
    DEFAULT_LOCATION = 9

    @staticmethod
    def find_by_category(category: int) -> QuerySet[NearbyPlace]:
        return NearbyPlace.objects.filter(category=category)

    @staticmethod
    def delete_by_category(category: int) -> bool:
        query = NearbyPlace.objects.filter(category=category)
        if query.count() > 0:
            query.delete()
            return True
        return False

    @staticmethod
    def bulk_create(objects: list[NearbyPlace]):
        NearbyPlace.objects.bulk_create(objects)

    @classmethod
    def get_default_location(cls) -> NearbyPlace:
        return NearbyPlace.objects.get(category=cls.DEFAULT_LOCATION)

    @staticmethod
    def handle_search_code(category: int, search_types: str, places: list[PlaceVO]):
        NearbyPlaceRepository.delete_by_category(category)
        if places:
            new_places = []
            for place in places:
                new_places.append(
                    NearbyPlace(
                        category=category,
                        search_types=search_types,
                        place_id=place.place_id,
                        name=place.name,
                        location=place.location.to_str(),
                        rating=place.rating,
                    )
                )
            NearbyPlaceRepository.bulk_create(new_places)
```

### domain/service/googlemaps.py
```py
import requests

from gmarker.domain.valueobject.googlemaps import PlaceVO
from lib.geo.valueobject.coords import GoogleMapCoords


class GoogleMapsService:
    def __init__(self, api_key: str):
        self.api_key = api_key
        self.base_url = "https://places.googleapis.com/v1/places"

    def nearby_search(
        self,
        center: GoogleMapCoords,
        search_types: list[str],
        radius: int,
        fields: list[str],
    ) -> list[PlaceVO]:
        """
        Google Maps Places APIのNearby Search (New)を使用して施設を検索します。

        Args:
            center: 検索中心の座標。
            search_types: 検索する場所のタイプ。
            radius: 検索半径（メートル）。
            fields: 取得するフィールドのリスト。

        Returns:
            検索結果の施設リスト。

        Raises:
            requests.HTTPError: HTTPエラーが発生した場合。
            ValueError: fieldsが空の場合。
        """

        if not fields:
            raise ValueError("fieldsパラメータは必須です")

        url = f"{self.base_url}:searchNearby"

        request_body = {
            "locationRestriction": {
                "circle": {
                    "center": {
                        "latitude": center.latitude,
                        "longitude": center.longitude,
                    },
                    "radius": radius,
                }
            },
            "includedTypes": search_types,
            "languageCode": "ja",
        }

        headers = {
            "Content-Type": "application/json",
            "X-Goog-Api-Key": self.api_key,
            "X-Goog-FieldMask": ",".join(fields),
        }

        try:
            response = requests.post(url, headers=headers, json=request_body)
            response.raise_for_status()
            data = response.json()
            places_data = data.get("places", [])

            places: list[PlaceVO] = []
            for place_data in places_data:
                latlng = place_data.get("location")
                if latlng:
                    latlng = GoogleMapCoords(
                        latitude=latlng.get("latitude"),
                        longitude=latlng.get("longitude"),
                    )

                places.append(
                    PlaceVO(
                        place_id=place_data.get("id"),
                        location=latlng,
                        name=place_data.get("displayName", {}).get("text"),
                        rating=place_data.get("rating"),
                        reviews=[],
                    )
                )
            return places

        except requests.HTTPError as e:
            print(f"An HTTP error occurred: {e}")
            return []
        except (KeyError, TypeError) as e:
            print(f"A data parsing error occurred: {e}")
            return []
        except Exception as e:
            print(f"An unexpected error occurred: {e}")
            return []
```

### domain/valueobject/googlemaps.py
```py
from dataclasses import dataclass

from lib.geo.valueobject.coords import GoogleMapCoords


@dataclass
class PlaceVO:
    """
    このデータクラスは、Google Places APIから取得した場所の詳細情報を表します。

    プロパティ:
        place_id: 場所のGoogle Places IDを表す文字列。
        location: 場所の座標を表すGoogleMapCoordsのインスタンス。
        name: 場所の名前を表す文字列。
        rating: 場所の評価を表す浮動小数点数。
        reviews: 将来的に場所のレビュー情報を含む可能性があるリスト。
    """

    place_id: str
    location: GoogleMapCoords
    name: str
    rating: float
    reviews: list
```

### fixtures/nearbyPlace.json
```json
[
  {
    "model": "gmarker.nearbyPlace",
    "fields": {
      "category": 9,
      "name": "渋谷駅",
      "location": "35.6598003,139.7023894",
      "created_at": "2023-02-12T09:00:00+09:00"
    }
  },
  {
    "model": "gmarker.nearbyPlace",
    "fields": {
      "category": 3,
      "name": "忠犬ハチ公像",
      "location": "35.6590439,139.7005917",
      "created_at": "2023-02-12T09:00:00+09:00"
    }
  },
  {
    "model": "gmarker.nearbyPlace",
    "fields": {
      "category": 3,
      "name": "明治神宮",
      "location": "35.676236,139.6993411",
      "created_at": "2023-02-12T09:00:00+09:00"
    }
  }
]
```

### models.py
```py
from django.db import models


class NearbyPlace(models.Model):
    """
    NearbyPlaceモデルはGoogle Placesから取得した近隣の場所に関する情報を保存します。
    ユーザーが画面から登録するのは "Category"と"Pin Select"です。 "Database Insert"は主にメンテナンスのときに使います。
    """

    category = (
        models.IntegerField()
    )  # TODO: 現在時点では自位置の9の特定に必要だがバラしてフィールドにしたら？
    search_types = models.CharField(null=True, blank=True, max_length=100)
    place_id = models.CharField(null=True, blank=True, max_length=200)
    name = models.CharField(max_length=200)
    location = models.CharField(max_length=100)
    rating = models.FloatField(null=True, blank=True)
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True, null=True)
```

### static/gmarker/js/map.js
```js
let infoWindow;

class MarkerData {
    constructor(data) {
        this.lat = data.location.lat;
        this.lng = data.location.lng;
        this.title = data.name || "マーカー";
        this.placeId = data.place_id;
        this.rating = data.rating;
    }

    toHtml() {
        return `
          <div>
            <p>${this.title}</p>
            <p>Place ID: ${this.placeId}<br>lat,lng: ${this.lat},${this.lng}<br>rating: ${this.rating}</p>
          </div>
        `;
    }
}

function initMap(jsonData) {
    try {
        if (!jsonData) {
            console.error("jsonData is not defined.");
            return;
        }

        const {Map, Marker} = google.maps;

        const options = {
            zoom: 14,
            center: new google.maps.LatLng(jsonData.center.lat, jsonData.center.lng),
            mapTypeId: google.maps.MapTypeId.ROADMAP,
            mapTypeControl: false,
            keyboardShortcuts: false,
            streetViewControl: false,
            fullscreenControl: false,
            scrollwheel: true
        };

        map = new Map(document.getElementById("map_canvas1"), options);
        createMarkersFromData(jsonData.places, Marker, map);

    } catch (e) {
        console.error("Google Maps API の初期化エラー:", e);
        alert("地図の読み込みに失敗しました。インターネット接続を確認してください。");
    }
}

function createMarkersFromData(places, Marker, map) {
    if (!map || !places || !Marker) {
        console.error("Missing map, places, or Marker");
        return;
    }

    places.map(place => {
        const markerData = new MarkerData(place);
        const marker = new Marker({
            position: new google.maps.LatLng(markerData.lat, markerData.lng),
            map: map,
            animation: google.maps.Animation.DROP,
            title: markerData.title
        });

        marker.addListener('click', () => {
            showInfoWindow(marker, markerData.toHtml());
        });

        return {marker, data: markerData};
    });
}

function showInfoWindow(marker, content) {
    if (infoWindow) {
        infoWindow.close();
    }
    infoWindow = new google.maps.InfoWindow({content});
    infoWindow.open(map, marker);
}
```

### templates/gmarker/base.html
`<!-- googlemap api -->` のあたりを参考にして

```html
{% load static %}
<!DOCTYPE html>
<html lang="ja">
<head>
    <!-- Global site tag (gtag.js) - Google Analytics -->
    <script async src="https://www.googletagmanager.com/gtag/js?id=UA-43097095-9"></script>
    <script>
        window.dataLayer = window.dataLayer || [];

        function gtag() {
            dataLayer.push(arguments);
        }

        gtag('js', new Date());
        gtag('config', 'UA-43097095-9');
    </script>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">

    <title>gmarker</title>

    <!-- bootstrap and css -->
    <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.2.1/css/bootstrap.min.css"
          integrity="sha384-GJzZqFGwb1QTTN6wy59ffF1BuGJpLSa9DkKMp0DgiMDm4iYMj70gZWKYbI706tWS" crossorigin="anonymous">
    <link rel="stylesheet" href="{% static 'gmarker/css/index.css' %}">
    <!-- favicon -->
    <link rel="icon" href="{% static 'gmarker/c_g.ico' %}">

    <!-- for ajax -->
    <script>let myUrl = {"base": "{% url 'mrk:index' %}"};</script>

    <!-- googlemap api -->
    <script src="{% static 'gmarker/js/map.js' %}" defer></script>
    <script>
        let map;
        window.myMapInit = () => {
            document.addEventListener('DOMContentLoaded', () => {
                const mapData = JSON.parse('{{ map_data|escapejs }}');
                map = initMap(mapData);
            });
        };
    </script>
    <script src="https://maps.googleapis.com/maps/api/js?key={{ google_maps_api_key }}&callback=myMapInit&libraries=places&v=weekly"
            async defer onerror="console.error('Google Maps API failed to load.');"></script>
    <script src="https://cdn.jsdelivr.net/npm/js-cookie@beta/dist/js.cookie.min.js"></script>
</head>

<body>
<h1></h1>
<header>
    <nav class="navbar fixed-top navbar-expand-lg navbar-light bg-light">
        <a class="navbar-brand" href="{% url 'home:index' %}">Henojiya</a>
        <button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarSupportedContent"
                aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
            <span class="navbar-toggler-icon"></span>
        </button>
        <div class="collapse navbar-collapse" id="navbarSupportedContent">
            <ul class="navbar-nav mr-auto">
                <li class="nav-item">
                    <select class="custom-select d-flex align-items-center" onChange="location.href=value;">
                        <option value="{% url 'home:index' %}">HOME</option>
                        <option value="{% url 'vnm:index' %}">VIETNAM</option>
                        <option value="{% url 'mrk:index' %}" selected>GMARKER</option>
                        <option value="{% url 'shp:index' %}">SHOPPING</option>
                        <option value="{% url 'war:index' %}">WAREHOUSE</option>
                        <option value="{% url 'txo:index' %}">TAXONOMY</option>
                        <option value="{% url 'soil:home' %}">SOIL ANALYSIS</option>
                        <option value="{% url 'sec:index' %}">SECURITIES REPORT</option>
                        <option value="{% url 'hsp:index' %}">HOSPITAL</option>
                        <option value="{% url 'llm:index' %}">LLM_CHAT</option>
                    </select>
                </li>
            </ul>
            <form class="form-inline my-2 my-lg-0">
                <input class="form-control mr-sm-2" type="search" placeholder="Search" aria-label="Search">
                <button class="btn btn-outline-success my-2 my-sm-0" type="submit">Search</button>
            </form>
        </div>
    </nav>
</header>

<div id="main">
    {% block content %}{% endblock %}
</div>
<footer>
    <p>© 2019 henojiya. / <a href="https://github.com/duri0214" target="_blank">github portfolio</a></p>
</footer>

<!-- Optional JavaScript -->
<!-- jQuery first, then Popper.js, then Bootstrap JS -->
<script src="https://code.jquery.com/jquery-3.7.1.min.js"
        integrity="sha256-/JqT3SQfawRcv/BIHPThkBvs0OEvtFFmqPF/lYI/Cxo=" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.14.6/umd/popper.min.js"
        integrity="sha384-wHAiFfRlMFy6i5SRaxvfOCifBUQy1xHdJ/yoi7FRNXMRBu5WHdZYu1hA6ZOblgut"
        crossorigin="anonymous"></script>
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.2.1/js/bootstrap.min.js"
        integrity="sha384-B0UglyR+jN6CkvvICOB2joaf5I4l3gm9GU6Hc1og6Ls7i6U/mkkaduKaBhlAXv9k"
        crossorigin="anonymous"></script>
</body>
</html>
```

### templates/gmarker/index.html
```html
{% extends "gmarker/base.html" %}
{% load static %}

{% block content %}

    <div class="jumbotron">
        <h1 class="display-4">Hello, googlemap!</h1>
        <div id="map_canvas1" style="width: 1000px; height: 500px"></div>
    </div>

    <h5>カテゴリーサーチ</h5>
    <form class="ml-4" action="{% url 'mrk:nearby_search' 1 %}" method="POST">
        {% csrf_token %}
        <input type="submit" class="btn-flat-border" value="レストラン"/> My拠点を中心に半径1500mのレストランのピンが表示される
    </form>
{% endblock %}
```

### tests/test_views.py
```py
from django.test import TestCase
from django.urls import reverse

from gmarker.models import NearbyPlace


class TestView(TestCase):
    @classmethod
    def setUpTestData(cls):
        NearbyPlace.objects.create(
            category=9,
            name="渋谷駅",
            location="35.6598003,139.7023894",
            created_at="2023-02-12 00:00:00",
        )
        NearbyPlace.objects.create(
            category=3,
            name="忠犬ハチ公像",
            location="35.6590439,139.7005917",
            created_at="2023-02-12 00:00:00",
        )
        NearbyPlace.objects.create(
            category=3,
            name="明治神宮",
            location="35.676236,139.6993411",
            created_at="2023-02-12 00:00:00",
        )

    def test_get_top_page_200(self):
        response = self.client.get(reverse("mrk:index"))
        self.assertEqual(200, response.status_code)
```

### urls.py
```py
from django.urls import path

from gmarker.views import IndexView

app_name = "mrk"
urlpatterns = [
    path("", IndexView.as_view(), name="index"),
    path("search/<int:search_code>", IndexView.as_view(), name="nearby_search"),
]
```

### views.py
```py
import json
import os

from django.shortcuts import redirect
from django.urls import reverse_lazy
from django.views.generic import TemplateView

from gmarker.domain.repository.googlemaps import NearbyPlaceRepository
from gmarker.domain.service.googlemaps import GoogleMapsService
from lib.geo.valueobject.coords import GoogleMapCoords


class IndexView(TemplateView):
    template_name = "gmarker/index.html"

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
        search_code = self.kwargs.get("search_code", 9)
        places = NearbyPlaceRepository.find_by_category(search_code)

        place_data = []
        for place in places:
            try:
                lat, lng = map(float, place.location.split(","))
                place_data.append(
                    {
                        "location": {
                            "lat": lat,
                            "lng": lng,
                        },
                        "name": place.name,
                        "place_id": place.place_id,
                        "rating": place.rating,
                    }
                )
            except ValueError:
                print(
                    f"Invalid location format: {place.location} for place {place.name}"
                )
                continue

        map_center = NearbyPlaceRepository.get_default_location()
        center_lat, center_lng = map(float, map_center.location.split(","))
        map_data = {
            "center": {
                "lat": center_lat,
                "lng": center_lng,
            },
            "places": place_data,
        }

        context["map_data"] = json.dumps(map_data, ensure_ascii=False)
        context["google_maps_api_key"] = os.getenv("GOOGLE_MAPS_API_KEY")
        return context

    @staticmethod
    def post(request, search_code: int):
        if search_code == NearbyPlaceRepository.CATEGORY_SEARCH:
            map_center = NearbyPlaceRepository.get_default_location()
            center_lat, center_lng = map(float, map_center.location.split(","))
            search_types = ["restaurant"]
            service = GoogleMapsService(os.getenv("GOOGLE_MAPS_API_KEY"))
            places = service.nearby_search(
                center=GoogleMapCoords(center_lat, center_lng),
                search_types=search_types,
                radius=1500,
                fields=[
                    "places.id",
                    "places.location",
                    "places.displayName.text",
                    "places.rating",
                ],
            )
            NearbyPlaceRepository.handle_search_code(
                category=NearbyPlaceRepository.CATEGORY_SEARCH,
                search_types=",".join(search_types),
                places=places,
            )
        return redirect(
            reverse_lazy("mrk:nearby_search", kwargs={"search_code": search_code})
        )
```