spb/lou-ka Public
Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 99.7%
1# -----------------------------------------------------------------------------2# Lou-Ka — Agrégateur de logements à louer (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# geocode.py : géocodage des adresses via Nominatim (OpenStreetMap)5# - cache persistant (table geocode_cache) : une adresse n'est géocodée6# qu'une seule fois, et par immeuble (adresse normalisée), pas par annonce7# - politesse : 1 requête/seconde max, User-Agent identifiable8# - validation : les coordonnées doivent tomber dans la région attendue9# (bounding box par ville), sinon flag geocode_failed — jamais de10# coordonnées bidon11# -----------------------------------------------------------------------------12from __future__ import annotations1314import re15import time1617import requests1819from . import db20from .normalize import strip_accents2122NOMINATIM_URL = "https://nominatim.openstreetmap.org/search"23# Repli officiel du gouvernement du Québec (Adresses Québec / MERN) : couvre24# les rues trop récentes pour OpenStreetMap (développements neufs de Lévis…)25AQ_URL = ("https://servicescarto.mern.gouv.qc.ca/pes/rest/services/Territoire/"26 "Adresse_Geocodage/GeocodeServer/findAddressCandidates")27AQ_MIN_SCORE = 7528# ASCII pur : le serveur ArcGIS d'Adresses Québec retourne 500 si l'en-tête29# User-Agent contient des accents (latin-1)30USER_AGENT = "LouKaBot/1.0 (agregateur logements Quebec; +contact@spboucher.ai)"31REQUEST_DELAY = 1.1 # règle Nominatim : max 1 req/s32RETRY_FAILED_AFTER = 30 * 86400 # re-tenter les échecs après 30 jours3334# Bounding boxes (lat_min, lat_max, lng_min, lng_max)35_BBOX_QUEBEC = (46.55, 47.10, -71.75, -70.85) # Québec, Lévis, St-Augustin36# garde-fou provincial : couvre Gatineau, l'Abitibi, le Saguenay, la Côte-Nord37# et la Gaspésie (expansion provinciale) — même boîte que schema.finalize()38_BBOX_SUD_QC = (44.50, 63.00, -80.00, -56.00)3940_VILLES_QUEBEC = {"quebec", "levis", "saint-augustin-de-desmaures",41 "l'ancienne-lorette", "ancienne-lorette"}424344def _bbox_for(city: str) -> tuple[float, float, float, float]:45 key = strip_accents((city or "").strip().lower())46 return _BBOX_QUEBEC if key in _VILLES_QUEBEC else _BBOX_SUD_QC474849def _in_bbox(lat: float, lng: float, bbox: tuple) -> bool:50 return bbox[0] <= lat <= bbox[1] and bbox[2] <= lng <= bbox[3]515253def norm_key(address: str) -> str:54 """Clé de cache : adresse normalisée (casse, accents, espaces, n° d'app.)."""55 s = strip_accents((address or "").lower())56 s = re.sub(r"\b(?:app?t?|appartement|unite|suite|#)\.?\s*[\w-]+\b", " ", s)57 s = re.sub(r"[^a-z0-9]+", " ", s)58 return re.sub(r"\s+", " ", s).strip()596061class Geocoder:62 def __init__(self, con) -> None:63 self.con = con64 self.session = requests.Session()65 self.session.headers["User-Agent"] = USER_AGENT66 self._last = 0.06768 @staticmethod69 def _clean(address: str) -> str:70 """Nettoyages qui aident Nominatim sur les adresses québécoises."""71 s = address.strip()72 # « 101-2905 Rue X » = unité 101, civique 2905 -> garder le civique73 s = re.sub(r"^(\d+)-(\d+)\s", r"\2 ", s)74 # « 6275 et 6375 boulevard X » -> premier civique75 s = re.sub(r"^(\d+)\s+et\s+\d+\s", r"\1 ", s)76 # « Montréal - Laval » / « Montréal - Île-des-Soeurs » -> garder le vrai lieu77 s = re.sub(r"montr[ée]al\s*-\s*", "", s, flags=re.I)78 # « bureau 105 » / « suite 3 » / « local B » : suffixes de bureau79 s = re.sub(r",?\s*(?:bureau|suite|local|app\.?|apt\.?)\s*[\w-]+\b", "", s, flags=re.I)80 # « Vanier (Québec) » -> « Vanier, Québec »81 s = re.sub(r"\s*\((qu[ée]bec)\)", r", \1", s, flags=re.I)82 # abréviations cardinales : « Rue Salaberry O » -> « Ouest »83 s = re.sub(r"\bO\.?(?=,|\s*$)", "Ouest", s)84 s = re.sub(r"\bE\.?(?=,|\s*$)", "Est", s)85 return s8687 def _build_query(self, address: str, city: str) -> str:88 q = self._clean(address)89 key = strip_accents(q.lower())90 if city and strip_accents(city.lower()) not in key:91 q += f", {city}"92 if "quebec" not in strip_accents(q.lower()) and "qc" not in q.lower():93 q += ", Québec"94 return q + ", Canada"9596 def _query_nominatim(self, params: dict) -> tuple[float, float] | None:97 """Une requête Nominatim, throttlée."""98 wait = REQUEST_DELAY - (time.time() - self._last)99 if wait > 0:100 time.sleep(wait)101 try:102 resp = self.session.get(NOMINATIM_URL, params={103 "format": "jsonv2", "limit": 1, "countrycodes": "ca", **params,104 }, timeout=20)105 self._last = time.time()106 resp.raise_for_status()107 hits = resp.json()108 except Exception:109 self._last = time.time()110 return None111 if not hits:112 return None113 try:114 return float(hits[0]["lat"]), float(hits[0]["lon"])115 except (KeyError, ValueError):116 return None117118 def _query_adresses_quebec(self, address: str, city: str) -> tuple[float, float] | None:119 """Repli : géocodeur officiel Adresses Québec (MERN, ArcGIS).120121 Couvre les rues trop récentes pour OSM. Seuil de score AQ_MIN_SCORE122 pour éviter les correspondances approximatives sur une autre rue.123 """124 premier = self._clean(address).split(",")[0].strip()125 ville = (city or "Québec").strip()126 wait = REQUEST_DELAY - (time.time() - self._last)127 if wait > 0:128 time.sleep(wait)129 try:130 resp = self.session.get(AQ_URL, params={131 "SingleLine": f"{premier}, {ville}",132 "f": "json", "outSR": 4326, "maxLocations": 1,133 }, timeout=20)134 self._last = time.time()135 resp.raise_for_status()136 cands = resp.json().get("candidates") or []137 except Exception:138 self._last = time.time()139 return None140 if not cands or cands[0].get("score", 0) < AQ_MIN_SCORE:141 return None142 loc = cands[0].get("location") or {}143 try:144 return float(loc["y"]), float(loc["x"])145 except (KeyError, ValueError):146 return None147148 def _attempts(self, address: str, city: str) -> list[dict]:149 """Stratégies de requête, de la plus précise à la moins précise.150151 1) structurée civique+rue (évite l'ambiguïté « Québec » ville/province)152 2) recherche libre complète153 3) structurée rue seule -> centroïde de rue (repli acceptable pour la154 carte quand le numéro civique est absent d'OpenStreetMap)155 """156 premier = self._clean(address).split(",")[0].strip()157 ville = (city or "").strip()158 if not ville:159 k = strip_accents(address.lower())160 ville = "Lévis" if "levis" in k else "Québec"161 commun = {"city": ville, "state": "Québec", "country": "Canada"}162163 tries: list[dict] = []164 m = re.match(r"^(\d+)[,\s]+(.{4,})$", premier)165 if m:166 tries.append({"street": f"{m.group(1)} {m.group(2)}", **commun})167 tries.append({"q": self._build_query(address, city)})168 if m:169 tries.append({"street": m.group(2), **commun})170 return tries171172 def resolve(self, address: str, city: str) -> tuple[float, float] | None:173 """Adresse -> (lat, lng), via cache puis Nominatim. None si introuvable."""174 key = norm_key(address)175 if not key or len(key) < 6:176 return None177 row = self.con.execute(178 "SELECT lat, lng, failed, ts FROM geocode_cache WHERE address=?",179 (key,)).fetchone()180 if row is not None:181 if not row["failed"]:182 return (row["lat"], row["lng"])183 if time.time() - (row["ts"] or 0) < RETRY_FAILED_AFTER:184 return None # échec récent : ne pas marteler l'API185186 bbox = _bbox_for(city)187 coords = None188 provider = "nominatim"189 for params in self._attempts(address, city):190 c = self._query_nominatim(params)191 if c is not None and _in_bbox(*c, bbox):192 coords = c193 break194 if coords is None:195 c = self._query_adresses_quebec(address, city)196 if c is not None and _in_bbox(*c, bbox):197 coords = c198 provider = "adresses_quebec"199 ok = coords is not None200 self.con.execute(201 "INSERT INTO geocode_cache (address, lat, lng, provider, failed, ts)"202 " VALUES (?,?,?,?,?,?)"203 " ON CONFLICT(address) DO UPDATE SET lat=excluded.lat,"204 " lng=excluded.lng, provider=excluded.provider,"205 " failed=excluded.failed, ts=excluded.ts",206 (key, coords[0] if ok else None, coords[1] if ok else None,207 provider, 0 if ok else 1, time.time()))208 self.con.commit()209 return coords if ok else None210211212def run(limit: int | None = None) -> dict:213 """Géocode les annonces actives sans coordonnées, par adresse unique.214215 `limit` borne le nombre de NOUVELLES requêtes Nominatim (les hits de216 cache sont gratuits et toujours appliqués).217 """218 con = db.connect()219 geo = Geocoder(con)220 rows = con.execute(221 """SELECT uid, address, city FROM listings222 WHERE active=1 AND lat IS NULL AND address<>'' AND geocode_failed=0223 ORDER BY address""").fetchall()224225 # regrouper par immeuble (adresse normalisée)226 groupes: dict[str, list] = {}227 for r in rows:228 groupes.setdefault(norm_key(r["address"]), []).append(r)229230 done = failed = requests_made = 0231 for key, members in groupes.items():232 if not key:233 continue234 cached = con.execute(235 "SELECT failed FROM geocode_cache WHERE address=?", (key,)).fetchone()236 if cached is None:237 if limit is not None and requests_made >= limit:238 continue239 requests_made += 1240 coords = geo.resolve(members[0]["address"], members[0]["city"])241 if coords:242 for r in members:243 con.execute("UPDATE listings SET lat=?, lng=? WHERE uid=?",244 (coords[0], coords[1], r["uid"]))245 done += len(members)246 else:247 # introuvable/hors zone : flag pour révision, jamais de coordonnées bidon248 for r in members:249 con.execute("UPDATE listings SET geocode_failed=1 WHERE uid=?",250 (r["uid"],))251 failed += len(members)252 con.commit()253254 con.close()255 stats = {"geocoded": done, "failed": failed,256 "unique_addresses": len(groupes), "api_requests": requests_made}257 print(f"[lou-ka] geocode {stats}")258 return stats259