# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # geocode.py : géocodage des adresses via Nominatim (OpenStreetMap) # - cache persistant (table geocode_cache) : une adresse n'est géocodée # qu'une seule fois, et par immeuble (adresse normalisée), pas par annonce # - politesse : 1 requête/seconde max, User-Agent identifiable # - validation : les coordonnées doivent tomber dans la région attendue # (bounding box par ville), sinon flag geocode_failed — jamais de # coordonnées bidon # ----------------------------------------------------------------------------- from __future__ import annotations import re import time import requests from . import db from .normalize import strip_accents NOMINATIM_URL = "https://nominatim.openstreetmap.org/search" # Repli officiel du gouvernement du Québec (Adresses Québec / MERN) : couvre # les rues trop récentes pour OpenStreetMap (développements neufs de Lévis…) AQ_URL = ("https://servicescarto.mern.gouv.qc.ca/pes/rest/services/Territoire/" "Adresse_Geocodage/GeocodeServer/findAddressCandidates") AQ_MIN_SCORE = 75 # ASCII pur : le serveur ArcGIS d'Adresses Québec retourne 500 si l'en-tête # User-Agent contient des accents (latin-1) USER_AGENT = "LouKaBot/1.0 (agregateur logements Quebec; +contact@spboucher.ai)" REQUEST_DELAY = 1.1 # règle Nominatim : max 1 req/s RETRY_FAILED_AFTER = 30 * 86400 # re-tenter les échecs après 30 jours # Bounding boxes (lat_min, lat_max, lng_min, lng_max) _BBOX_QUEBEC = (46.55, 47.10, -71.75, -70.85) # Québec, Lévis, St-Augustin # garde-fou provincial : couvre Gatineau, l'Abitibi, le Saguenay, la Côte-Nord # et la Gaspésie (expansion provinciale) — même boîte que schema.finalize() _BBOX_SUD_QC = (44.50, 63.00, -80.00, -56.00) _VILLES_QUEBEC = {"quebec", "levis", "saint-augustin-de-desmaures", "l'ancienne-lorette", "ancienne-lorette"} def _bbox_for(city: str) -> tuple[float, float, float, float]: key = strip_accents((city or "").strip().lower()) return _BBOX_QUEBEC if key in _VILLES_QUEBEC else _BBOX_SUD_QC def _in_bbox(lat: float, lng: float, bbox: tuple) -> bool: return bbox[0] <= lat <= bbox[1] and bbox[2] <= lng <= bbox[3] def norm_key(address: str) -> str: """Clé de cache : adresse normalisée (casse, accents, espaces, n° d'app.).""" s = strip_accents((address or "").lower()) s = re.sub(r"\b(?:app?t?|appartement|unite|suite|#)\.?\s*[\w-]+\b", " ", s) s = re.sub(r"[^a-z0-9]+", " ", s) return re.sub(r"\s+", " ", s).strip() class Geocoder: def __init__(self, con) -> None: self.con = con self.session = requests.Session() self.session.headers["User-Agent"] = USER_AGENT self._last = 0.0 @staticmethod def _clean(address: str) -> str: """Nettoyages qui aident Nominatim sur les adresses québécoises.""" s = address.strip() # « 101-2905 Rue X » = unité 101, civique 2905 -> garder le civique s = re.sub(r"^(\d+)-(\d+)\s", r"\2 ", s) # « 6275 et 6375 boulevard X » -> premier civique s = re.sub(r"^(\d+)\s+et\s+\d+\s", r"\1 ", s) # « Montréal - Laval » / « Montréal - Île-des-Soeurs » -> garder le vrai lieu s = re.sub(r"montr[ée]al\s*-\s*", "", s, flags=re.I) # « bureau 105 » / « suite 3 » / « local B » : suffixes de bureau s = re.sub(r",?\s*(?:bureau|suite|local|app\.?|apt\.?)\s*[\w-]+\b", "", s, flags=re.I) # « Vanier (Québec) » -> « Vanier, Québec » s = re.sub(r"\s*\((qu[ée]bec)\)", r", \1", s, flags=re.I) # abréviations cardinales : « Rue Salaberry O » -> « Ouest » s = re.sub(r"\bO\.?(?=,|\s*$)", "Ouest", s) s = re.sub(r"\bE\.?(?=,|\s*$)", "Est", s) return s def _build_query(self, address: str, city: str) -> str: q = self._clean(address) key = strip_accents(q.lower()) if city and strip_accents(city.lower()) not in key: q += f", {city}" if "quebec" not in strip_accents(q.lower()) and "qc" not in q.lower(): q += ", Québec" return q + ", Canada" def _query_nominatim(self, params: dict) -> tuple[float, float] | None: """Une requête Nominatim, throttlée.""" wait = REQUEST_DELAY - (time.time() - self._last) if wait > 0: time.sleep(wait) try: resp = self.session.get(NOMINATIM_URL, params={ "format": "jsonv2", "limit": 1, "countrycodes": "ca", **params, }, timeout=20) self._last = time.time() resp.raise_for_status() hits = resp.json() except Exception: self._last = time.time() return None if not hits: return None try: return float(hits[0]["lat"]), float(hits[0]["lon"]) except (KeyError, ValueError): return None def _query_adresses_quebec(self, address: str, city: str) -> tuple[float, float] | None: """Repli : géocodeur officiel Adresses Québec (MERN, ArcGIS). Couvre les rues trop récentes pour OSM. Seuil de score AQ_MIN_SCORE pour éviter les correspondances approximatives sur une autre rue. """ premier = self._clean(address).split(",")[0].strip() ville = (city or "Québec").strip() wait = REQUEST_DELAY - (time.time() - self._last) if wait > 0: time.sleep(wait) try: resp = self.session.get(AQ_URL, params={ "SingleLine": f"{premier}, {ville}", "f": "json", "outSR": 4326, "maxLocations": 1, }, timeout=20) self._last = time.time() resp.raise_for_status() cands = resp.json().get("candidates") or [] except Exception: self._last = time.time() return None if not cands or cands[0].get("score", 0) < AQ_MIN_SCORE: return None loc = cands[0].get("location") or {} try: return float(loc["y"]), float(loc["x"]) except (KeyError, ValueError): return None def _attempts(self, address: str, city: str) -> list[dict]: """Stratégies de requête, de la plus précise à la moins précise. 1) structurée civique+rue (évite l'ambiguïté « Québec » ville/province) 2) recherche libre complète 3) structurée rue seule -> centroïde de rue (repli acceptable pour la carte quand le numéro civique est absent d'OpenStreetMap) """ premier = self._clean(address).split(",")[0].strip() ville = (city or "").strip() if not ville: k = strip_accents(address.lower()) ville = "Lévis" if "levis" in k else "Québec" commun = {"city": ville, "state": "Québec", "country": "Canada"} tries: list[dict] = [] m = re.match(r"^(\d+)[,\s]+(.{4,})$", premier) if m: tries.append({"street": f"{m.group(1)} {m.group(2)}", **commun}) tries.append({"q": self._build_query(address, city)}) if m: tries.append({"street": m.group(2), **commun}) return tries def resolve(self, address: str, city: str) -> tuple[float, float] | None: """Adresse -> (lat, lng), via cache puis Nominatim. None si introuvable.""" key = norm_key(address) if not key or len(key) < 6: return None row = self.con.execute( "SELECT lat, lng, failed, ts FROM geocode_cache WHERE address=?", (key,)).fetchone() if row is not None: if not row["failed"]: return (row["lat"], row["lng"]) if time.time() - (row["ts"] or 0) < RETRY_FAILED_AFTER: return None # échec récent : ne pas marteler l'API bbox = _bbox_for(city) coords = None provider = "nominatim" for params in self._attempts(address, city): c = self._query_nominatim(params) if c is not None and _in_bbox(*c, bbox): coords = c break if coords is None: c = self._query_adresses_quebec(address, city) if c is not None and _in_bbox(*c, bbox): coords = c provider = "adresses_quebec" ok = coords is not None self.con.execute( "INSERT INTO geocode_cache (address, lat, lng, provider, failed, ts)" " VALUES (?,?,?,?,?,?)" " ON CONFLICT(address) DO UPDATE SET lat=excluded.lat," " lng=excluded.lng, provider=excluded.provider," " failed=excluded.failed, ts=excluded.ts", (key, coords[0] if ok else None, coords[1] if ok else None, provider, 0 if ok else 1, time.time())) self.con.commit() return coords if ok else None def run(limit: int | None = None) -> dict: """Géocode les annonces actives sans coordonnées, par adresse unique. `limit` borne le nombre de NOUVELLES requêtes Nominatim (les hits de cache sont gratuits et toujours appliqués). """ con = db.connect() geo = Geocoder(con) rows = con.execute( """SELECT uid, address, city FROM listings WHERE active=1 AND lat IS NULL AND address<>'' AND geocode_failed=0 ORDER BY address""").fetchall() # regrouper par immeuble (adresse normalisée) groupes: dict[str, list] = {} for r in rows: groupes.setdefault(norm_key(r["address"]), []).append(r) done = failed = requests_made = 0 for key, members in groupes.items(): if not key: continue cached = con.execute( "SELECT failed FROM geocode_cache WHERE address=?", (key,)).fetchone() if cached is None: if limit is not None and requests_made >= limit: continue requests_made += 1 coords = geo.resolve(members[0]["address"], members[0]["city"]) if coords: for r in members: con.execute("UPDATE listings SET lat=?, lng=? WHERE uid=?", (coords[0], coords[1], r["uid"])) done += len(members) else: # introuvable/hors zone : flag pour révision, jamais de coordonnées bidon for r in members: con.execute("UPDATE listings SET geocode_failed=1 WHERE uid=?", (r["uid"],)) failed += len(members) con.commit() con.close() stats = {"geocoded": done, "failed": failed, "unique_addresses": len(groupes), "api_requests": requests_made} print(f"[lou-ka] geocode {stats}") return stats