Skip to content

Commit d170cd9

Browse files
Add Google Places ETL pipelines
1 parent 3e82b91 commit d170cd9

5 files changed

Lines changed: 207 additions & 0 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""Google Places ETL pipelines."""
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
from __future__ import annotations
2+
3+
from dataclasses import dataclass
4+
from typing import Any
5+
6+
from dtos import Activity, Address, Availability, Contact, ItemCategory, Service
7+
8+
9+
@dataclass(frozen=True)
10+
class GooglePlacesQuery:
11+
text_query: str
12+
data_source: str
13+
item_category: ItemCategory
14+
activity: Activity
15+
16+
17+
def extract_postcode(formatted_address: str | None) -> str | None:
18+
if not formatted_address:
19+
return None
20+
parts = formatted_address.split()
21+
for part in parts:
22+
if len(part) == 5 and part.isdigit():
23+
return part
24+
return None
25+
26+
27+
def normalize_google_place(
28+
raw: dict[str, Any],
29+
*,
30+
data_source: str,
31+
data_source_id: str,
32+
item_category: ItemCategory,
33+
activity: Activity,
34+
) -> dict[str, Any]:
35+
display_name = raw.get("displayName") or {}
36+
formatted_address = raw.get("formattedAddress")
37+
location = raw.get("location") or {}
38+
return {
39+
"data_source_id": data_source_id,
40+
"data_source": data_source,
41+
"name": display_name.get("text") or data_source_id,
42+
"lat": location.get("latitude", 0.0),
43+
"lon": location.get("longitude", 0.0),
44+
"address": Address(
45+
street=formatted_address,
46+
postcode=extract_postcode(formatted_address),
47+
),
48+
"contact": Contact(),
49+
"services": [Service(activity=activity, item_category=item_category)],
50+
"availability": Availability(),
51+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
from __future__ import annotations
2+
3+
from dtos import Activity, ItemCategory, NormalizedLocation, RawLocation
4+
from base.normalizer import BaseNormalizer
5+
6+
from pipelines.google_places.common import normalize_google_place
7+
8+
9+
class GooglePlacesRepairNormalizer(BaseNormalizer):
10+
def normalize(self, raw_locations: list[RawLocation]) -> list[NormalizedLocation]:
11+
return [
12+
NormalizedLocation(**normalize_google_place(
13+
raw.payload,
14+
data_source=raw.data_source,
15+
data_source_id=raw.data_source_id,
16+
item_category=ItemCategory.SHOES,
17+
activity=Activity.REPAIR_PAID,
18+
))
19+
for raw in raw_locations
20+
]
21+
22+
23+
class GooglePlacesDonationNormalizer(BaseNormalizer):
24+
def normalize(self, raw_locations: list[RawLocation]) -> list[NormalizedLocation]:
25+
return [
26+
NormalizedLocation(**normalize_google_place(
27+
raw.payload,
28+
data_source=raw.data_source,
29+
data_source_id=raw.data_source_id,
30+
item_category=ItemCategory.CLOTHING,
31+
activity=Activity.DONATION_DROP,
32+
))
33+
for raw in raw_locations
34+
]
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
from __future__ import annotations
2+
3+
from datetime import datetime, timezone
4+
from urllib.error import HTTPError, URLError
5+
from urllib.request import Request, urlopen
6+
import json
7+
import os
8+
from typing import Any
9+
10+
from base.querier import BaseQuerier
11+
from dtos import RawLocation
12+
13+
14+
GOOGLE_PLACES_ENDPOINT = "https://places.googleapis.com/v1/places:searchText"
15+
16+
17+
class GooglePlacesQuerier(BaseQuerier):
18+
def __init__(self, *, api_key: str | None = None, text_query: str, data_source: str):
19+
self.api_key = api_key or os.environ.get("GOOGLE_API_KEY")
20+
self.text_query = text_query
21+
self.data_source = data_source
22+
23+
def fetch(self) -> list[RawLocation]:
24+
if not self.api_key:
25+
raise RuntimeError("GOOGLE_API_KEY is required")
26+
27+
payload = self._request({"textQuery": self.text_query})
28+
places = payload.get("places", [])
29+
fetched_at = datetime.now(timezone.utc)
30+
raw_locations: list[RawLocation] = []
31+
for index, place in enumerate(places):
32+
raw_locations.append(
33+
RawLocation(
34+
data_source=self.data_source,
35+
data_source_id=place.get("id") or f"{self.data_source}-{index}",
36+
fetched_at=fetched_at,
37+
payload=place,
38+
)
39+
)
40+
return raw_locations
41+
42+
def _request(self, body: dict[str, Any]) -> dict[str, Any]:
43+
request = Request(
44+
GOOGLE_PLACES_ENDPOINT,
45+
data=json.dumps(body).encode("utf-8"),
46+
headers={
47+
"Content-Type": "application/json",
48+
"X-Goog-Api-Key": self.api_key,
49+
"X-Goog-FieldMask": "places.id,places.displayName,places.formattedAddress,places.location,places.types",
50+
},
51+
method="POST",
52+
)
53+
try:
54+
with urlopen(request, timeout=30) as response:
55+
return json.loads(response.read().decode("utf-8"))
56+
except (HTTPError, URLError) as exc:
57+
raise RuntimeError(f"Google Places request failed: {exc}") from exc
58+
59+
60+
class GooglePlacesRepairQuerier(GooglePlacesQuerier):
61+
def __init__(self, api_key: str | None = None):
62+
super().__init__(api_key=api_key, text_query="shoe repair Boston MA", data_source="google_places_repair")
63+
64+
65+
class GooglePlacesDonationQuerier(GooglePlacesQuerier):
66+
def __init__(self, api_key: str | None = None):
67+
super().__init__(api_key=api_key, text_query="donation centers Boston MA", data_source="google_places_donations")
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
from datetime import datetime, timezone
2+
from pathlib import Path
3+
import json
4+
5+
from dtos import Activity, ItemCategory, RawLocation
6+
from pipelines.google_places.normalizer import (
7+
GooglePlacesDonationNormalizer,
8+
GooglePlacesRepairNormalizer,
9+
)
10+
11+
12+
SAMPLES = Path(__file__).resolve().parents[3] / "data-explorations" / "google-places" / "samples"
13+
14+
15+
def _load_places(name: str):
16+
return json.loads((SAMPLES / name).read_text())["places"]
17+
18+
19+
def _raw_locations(data_source: str, prefix: str, filename: str):
20+
fetched_at = datetime.now(timezone.utc)
21+
return [
22+
RawLocation(
23+
data_source=data_source,
24+
data_source_id=f"{prefix}-{index}",
25+
fetched_at=fetched_at,
26+
payload=place,
27+
)
28+
for index, place in enumerate(_load_places(filename))
29+
]
30+
31+
32+
def test_repair_normalizer_maps_google_places_sample():
33+
normalized = GooglePlacesRepairNormalizer().normalize(
34+
_raw_locations("google_places_repair", "repair", "shoe-repair-00.json")
35+
)
36+
37+
assert normalized
38+
assert normalized[0].data_source == "google_places_repair"
39+
assert normalized[0].services[0].activity == Activity.REPAIR_PAID
40+
assert normalized[0].services[0].item_category == ItemCategory.SHOES
41+
assert normalized[0].name == "David's Instant Shoe Repair"
42+
assert normalized[0].address.street == "281 Franklin St, Boston, MA 02110, USA"
43+
44+
45+
def test_donation_normalizer_maps_google_places_sample():
46+
normalized = GooglePlacesDonationNormalizer().normalize(
47+
_raw_locations("google_places_donations", "donation", "donations-00.json")
48+
)
49+
50+
assert normalized
51+
assert normalized[0].data_source == "google_places_donations"
52+
assert normalized[0].services[0].activity == Activity.DONATION_DROP
53+
assert normalized[0].services[0].item_category == ItemCategory.CLOTHING
54+
assert normalized[0].name == "Morgan Memorial Goodwill Industries"

0 commit comments

Comments
 (0)