359 lines
12 KiB
Python
359 lines
12 KiB
Python
"""Google Places sync + entity enrichment for Export Intel."""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import math
|
|
import os
|
|
import time
|
|
from typing import Any, Optional
|
|
|
|
import httpx
|
|
|
|
from app.db import execute, fetch_all, fetch_one
|
|
from app.export_intel_sync import _get_territory, _upsert_contact
|
|
|
|
PLACES_URL = "https://maps.googleapis.com/maps/api/place/textsearch/json"
|
|
DETAILS_URL = "https://maps.googleapis.com/maps/api/place/details/json"
|
|
FIND_URL = "https://maps.googleapis.com/maps/api/place/findplacefromtext/json"
|
|
|
|
SETTINGS_KEY = "export_intel.google_maps_api_key"
|
|
|
|
QUERIES = [
|
|
"groothandel groente fruit",
|
|
"wholesale food",
|
|
"meat wholesaler",
|
|
"halal meat distributor",
|
|
"poultry distributor",
|
|
"frozen food supplier",
|
|
"contract catering",
|
|
"food importer",
|
|
"halal butcher",
|
|
"horeca groothandel",
|
|
]
|
|
|
|
LARGE_COUNTRIES = frozenset({"US", "CA", "BR", "MX", "AR", "AU", "RU", "CN", "IN", "CD", "DZ", "EG", "IR", "SA"})
|
|
|
|
|
|
def get_google_api_key() -> Optional[str]:
|
|
env = (os.getenv("GOOGLE_MAPS_API_KEY") or os.getenv("GOOGLE_PLACES_API_KEY") or "").strip()
|
|
if env:
|
|
return env
|
|
row = fetch_one("SELECT value FROM app_settings WHERE key = %s", (SETTINGS_KEY,))
|
|
if not row or not row.get("value"):
|
|
return None
|
|
val = row["value"]
|
|
if isinstance(val, dict):
|
|
key = (val.get("api_key") or val.get("key") or "").strip()
|
|
return key or None
|
|
if isinstance(val, str):
|
|
try:
|
|
parsed = json.loads(val)
|
|
if isinstance(parsed, dict):
|
|
return (parsed.get("api_key") or parsed.get("key") or "").strip() or None
|
|
except Exception:
|
|
return val.strip() or None
|
|
return None
|
|
|
|
|
|
def set_google_api_key(api_key: str) -> None:
|
|
key = (api_key or "").strip()
|
|
if not key:
|
|
execute("DELETE FROM app_settings WHERE key = %s", (SETTINGS_KEY,))
|
|
return
|
|
execute(
|
|
"""
|
|
INSERT INTO app_settings (key, value, updated_at)
|
|
VALUES (%s, %s::jsonb, NOW())
|
|
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()
|
|
""",
|
|
(SETTINGS_KEY, json.dumps({"api_key": key})),
|
|
)
|
|
|
|
|
|
def google_api_key_status() -> dict[str, Any]:
|
|
key = get_google_api_key()
|
|
return {
|
|
"google_maps_api_key_set": bool(key),
|
|
"source": "env" if (os.getenv("GOOGLE_MAPS_API_KEY") or os.getenv("GOOGLE_PLACES_API_KEY")) else ("db" if key else None),
|
|
}
|
|
|
|
|
|
def _api_key() -> Optional[str]:
|
|
return get_google_api_key()
|
|
|
|
|
|
def _places_search(query: str, lat: float, lon: float, radius_m: int = 80000) -> list[dict[str, Any]]:
|
|
key = _api_key()
|
|
if not key:
|
|
return []
|
|
params = {
|
|
"query": query,
|
|
"location": f"{lat},{lon}",
|
|
"radius": radius_m,
|
|
"key": key,
|
|
}
|
|
with httpx.Client(timeout=30.0) as client:
|
|
resp = client.get(PLACES_URL, params=params)
|
|
if resp.status_code >= 400:
|
|
return []
|
|
data = resp.json()
|
|
if data.get("status") not in ("OK", "ZERO_RESULTS"):
|
|
return []
|
|
return data.get("results", [])[:12]
|
|
|
|
|
|
def _find_place_id(name: str, lat: float, lon: float, radius_m: int = 3000) -> Optional[str]:
|
|
key = _api_key()
|
|
if not key or not name.strip():
|
|
return None
|
|
params = {
|
|
"input": name.strip()[:128],
|
|
"inputtype": "textquery",
|
|
"locationbias": f"circle:{radius_m}@{lat},{lon}",
|
|
"fields": "place_id,name,geometry",
|
|
"key": key,
|
|
}
|
|
with httpx.Client(timeout=20.0) as client:
|
|
resp = client.get(FIND_URL, params=params)
|
|
if resp.status_code >= 400:
|
|
return None
|
|
data = resp.json()
|
|
if data.get("status") != "OK":
|
|
return None
|
|
cands = data.get("candidates") or []
|
|
if not cands:
|
|
return None
|
|
best = cands[0]
|
|
geo = best.get("geometry", {}).get("location", {})
|
|
clat, clon = geo.get("lat"), geo.get("lng")
|
|
if clat is not None and clon is not None:
|
|
dist = _haversine_m(lat, lon, float(clat), float(clon))
|
|
if dist > max(radius_m * 2, 8000):
|
|
return None
|
|
return best.get("place_id")
|
|
|
|
|
|
def _haversine_m(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
|
|
r = 6371000.0
|
|
p1, p2 = math.radians(lat1), math.radians(lat2)
|
|
dp = math.radians(lat2 - lat1)
|
|
dl = math.radians(lon2 - lon1)
|
|
a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2
|
|
return 2 * r * math.asin(math.sqrt(a))
|
|
|
|
|
|
def _place_details(place_id: str) -> dict[str, Any]:
|
|
key = _api_key()
|
|
if not key:
|
|
return {}
|
|
with httpx.Client(timeout=20.0) as client:
|
|
resp = client.get(
|
|
DETAILS_URL,
|
|
params={
|
|
"place_id": place_id,
|
|
"fields": "name,formatted_phone_number,website,formatted_address,geometry,international_phone_number,place_id",
|
|
"key": key,
|
|
},
|
|
)
|
|
if resp.status_code >= 400:
|
|
return {}
|
|
return resp.json().get("result", {})
|
|
|
|
|
|
def _bbox_points(bbox: Any, grid: int = 3) -> list[tuple[float, float]]:
|
|
if isinstance(bbox, str):
|
|
bbox = json.loads(bbox)
|
|
if not bbox or len(bbox) != 4:
|
|
return []
|
|
south, west, north, east = bbox
|
|
points: list[tuple[float, float]] = []
|
|
for i in range(grid):
|
|
for j in range(grid):
|
|
lat = south + (north - south) * (i + 0.5) / grid
|
|
lon = west + (east - west) * (j + 0.5) / grid
|
|
points.append((lat, lon))
|
|
return points
|
|
|
|
|
|
def _apply_place_to_entity(entity_id: int, place_id: str, details: dict[str, Any]) -> bool:
|
|
phone = details.get("formatted_phone_number") or details.get("international_phone_number")
|
|
website = details.get("website")
|
|
geo = details.get("geometry", {}).get("location", {})
|
|
plat, plon = geo.get("lat"), geo.get("lng")
|
|
execute(
|
|
"""
|
|
UPDATE export_market_entities SET
|
|
phone = COALESCE(%s, phone),
|
|
website = COALESCE(%s, website),
|
|
google_place_id = COALESCE(google_place_id, %s),
|
|
lat = COALESCE(%s, lat),
|
|
lon = COALESCE(%s, lon),
|
|
confidence = GREATEST(confidence, 68),
|
|
updated_at = NOW(),
|
|
metadata = COALESCE(metadata, '{}'::jsonb) || %s::jsonb
|
|
WHERE id = %s
|
|
""",
|
|
(
|
|
phone,
|
|
website,
|
|
place_id,
|
|
plat,
|
|
plon,
|
|
json.dumps({"google_place_id": place_id, "google_enriched": True}),
|
|
entity_id,
|
|
),
|
|
)
|
|
if phone:
|
|
_upsert_contact(entity_id, None, phone, "google_places", 72, role="sales", name="Google Places")
|
|
return bool(phone or website)
|
|
|
|
|
|
def _upsert_place(place: dict[str, Any], details: dict[str, Any], country_iso2: str, territory_code: str, query: str) -> tuple[str, int]:
|
|
from app.db import execute_returning
|
|
|
|
pid = place.get("place_id") or details.get("place_id")
|
|
if not pid:
|
|
return "skipped", 0
|
|
name = details.get("name") or place.get("name") or "Unknown"
|
|
geo = details.get("geometry", {}).get("location", place.get("geometry", {}).get("location", {}))
|
|
plat = geo.get("lat")
|
|
plon = geo.get("lng")
|
|
phone = details.get("formatted_phone_number") or details.get("international_phone_number")
|
|
website = details.get("website")
|
|
entity_type = "contract_caterer" if "catering" in query else "wholesaler"
|
|
|
|
existing = fetch_one(
|
|
"SELECT id FROM export_market_entities WHERE google_place_id = %s",
|
|
(pid,),
|
|
)
|
|
if existing:
|
|
_apply_place_to_entity(int(existing["id"]), pid, details)
|
|
return "updated", int(existing["id"])
|
|
|
|
row = execute_returning(
|
|
"""
|
|
INSERT INTO export_market_entities (
|
|
name, entity_type, country_iso2, territory_code, lat, lon,
|
|
phone, website, google_place_id, source, sources, confidence, metadata
|
|
) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,'google_places','["google_places"]'::jsonb,65,%s)
|
|
RETURNING id
|
|
""",
|
|
(
|
|
name[:255],
|
|
entity_type,
|
|
country_iso2.upper(),
|
|
territory_code,
|
|
plat,
|
|
plon,
|
|
phone,
|
|
website,
|
|
pid,
|
|
json.dumps({"google_place_id": pid}),
|
|
),
|
|
)
|
|
entity_id = int(row["id"])
|
|
if phone:
|
|
_upsert_contact(entity_id, None, phone, "google_places", 70, role="sales", name="Google Places")
|
|
return "inserted", entity_id
|
|
|
|
|
|
def enrich_entities_places_country(country_iso2: str, limit: int = 400) -> dict[str, Any]:
|
|
"""Match existing OSM entities to Google Places for phone/website."""
|
|
if not _api_key():
|
|
return {"skipped": True, "reason": "GOOGLE_MAPS_API_KEY not set"}
|
|
|
|
entities = fetch_all(
|
|
"""
|
|
SELECT id, name, lat, lon, city, phone, website
|
|
FROM export_market_entities
|
|
WHERE country_iso2 = %s
|
|
AND lat IS NOT NULL AND lon IS NOT NULL
|
|
AND (phone IS NULL OR phone = '')
|
|
AND (google_place_id IS NULL OR google_place_id = '')
|
|
ORDER BY confidence DESC NULLS LAST, id
|
|
LIMIT %s
|
|
""",
|
|
(country_iso2.upper(), limit),
|
|
)
|
|
enriched = 0
|
|
not_found = 0
|
|
errors = 0
|
|
for ent in entities:
|
|
query = ent["name"] or ""
|
|
if ent.get("city"):
|
|
query = f"{query} {ent['city']}"
|
|
try:
|
|
pid = _find_place_id(query, float(ent["lat"]), float(ent["lon"]))
|
|
if not pid:
|
|
not_found += 1
|
|
time.sleep(0.12)
|
|
continue
|
|
details = _place_details(pid)
|
|
if _apply_place_to_entity(int(ent["id"]), pid, details):
|
|
enriched += 1
|
|
except Exception:
|
|
errors += 1
|
|
time.sleep(0.15)
|
|
return {
|
|
"country": country_iso2.upper(),
|
|
"candidates": len(entities),
|
|
"enriched": enriched,
|
|
"not_found": not_found,
|
|
"errors": errors,
|
|
}
|
|
|
|
|
|
def sync_places_country(country_iso2: str) -> dict[str, Any]:
|
|
territory = _get_territory(country_iso2)
|
|
if not territory or not territory.get("lat"):
|
|
return {"skipped": True, "reason": "no territory"}
|
|
if not _api_key():
|
|
return {"skipped": True, "reason": "GOOGLE_MAPS_API_KEY not set"}
|
|
|
|
territory_code = territory["code"]
|
|
inserted = updated = 0
|
|
seen: set[str] = set()
|
|
|
|
iso = country_iso2.upper()
|
|
if iso in LARGE_COUNTRIES and territory.get("bbox"):
|
|
search_points = _bbox_points(territory["bbox"], grid=3)
|
|
else:
|
|
search_points = [(territory["lat"], territory["lon"])]
|
|
|
|
radius = 60000 if iso in LARGE_COUNTRIES else 80000
|
|
|
|
for lat, lon in search_points:
|
|
for q in QUERIES:
|
|
for place in _places_search(q, lat, lon, radius_m=radius):
|
|
pid = place.get("place_id")
|
|
if not pid or pid in seen:
|
|
continue
|
|
seen.add(pid)
|
|
details = _place_details(pid)
|
|
action, _ = _upsert_place(place, details, iso, territory_code, q)
|
|
if action == "inserted":
|
|
inserted += 1
|
|
elif action == "updated":
|
|
updated += 1
|
|
time.sleep(0.15)
|
|
time.sleep(0.5)
|
|
|
|
enrich = enrich_entities_places_country(iso, limit=500)
|
|
return {
|
|
"inserted": inserted,
|
|
"updated": updated,
|
|
"queries": len(QUERIES),
|
|
"search_points": len(search_points),
|
|
"enrich_existing": enrich,
|
|
}
|
|
|
|
|
|
def sync_places_countries(countries: list[str]) -> dict[str, Any]:
|
|
if not _api_key():
|
|
return {"skipped": True, "reason": "GOOGLE_MAPS_API_KEY not set"}
|
|
out: dict[str, Any] = {}
|
|
for c in countries:
|
|
out[c] = sync_places_country(c)
|
|
time.sleep(1)
|
|
return out
|