Python 67%
TypeScript 18.2%
CSS 14.4%
1# -----------------------------------------------------------------------------2# Immo-Ka — Agrégateur de propriétés à vendre (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# - rigueur : chaque résultat est validé contre le CENTROÏDE de la ville12# annoncée (médiane des fiches déjà géocodées, sinon Nominatim, en cache) —13# à plus de VILLE_RADIUS_KM du centroïde, le candidat est rejeté. Corrige14# la classe de bug « rue homonyme dans une autre ville » (ex. rue des15# Perdrix de Jonquière géocodée à Saint-Augustin).16# -----------------------------------------------------------------------------17from __future__ import annotations1819import json20import math21import sqlite322import statistics2324import re25import time2627import requests2829from . import db30from .normalize import strip_accents3132NOMINATIM_URL = "https://nominatim.openstreetmap.org/search"33# Repli officiel du gouvernement du Québec (Adresses Québec / MERN) : couvre34# les rues trop récentes pour OpenStreetMap (développements neufs de Lévis…)35AQ_URL = ("https://servicescarto.mern.gouv.qc.ca/pes/rest/services/Territoire/"36 "Adresse_Geocodage/GeocodeServer/findAddressCandidates")37AQ_MIN_SCORE = 7538# ASCII pur : le serveur ArcGIS d'Adresses Québec retourne 500 si l'en-tête39# User-Agent contient des accents (latin-1)40USER_AGENT = "ImmoKaBot/1.0 (agregateur logements Quebec; +contact@spboucher.ai)"41REQUEST_DELAY = 1.1 # règle Nominatim : max 1 req/s42REQUEST_DELAY_AQ = 0.12 # Adresses Québec (MERN) : pas de limite stricte43RETRY_FAILED_AFTER = 30 * 86400 # re-tenter les échecs après 30 jours4445# Bounding boxes (lat_min, lat_max, lng_min, lng_max)46_BBOX_QUEBEC = (46.55, 47.10, -71.75, -70.85) # Québec, Lévis, St-Augustin47# garde-fou provincial : couvre Gatineau, l'Abitibi, le Saguenay, la Côte-Nord48# et la Gaspésie (expansion provinciale)49_BBOX_SUD_QC = (44.50, 63.00, -80.00, -56.00)50# territoire complet Groupe Ka : Québec + Ontario (Windsor -83°, Kenora -94.5°)51# — même boîte que schema.finalize()52_BBOX_KA = (41.60, 63.00, -95.50, -56.00)5354_VILLES_QUEBEC = {"quebec", "levis", "saint-augustin-de-desmaures",55 "l'ancienne-lorette", "ancienne-lorette"}5657# Validation « rigoureuse » : distance max entre un résultat de géocodage et une58# référence de la ville annoncée. 40 km tolère les grandes municipalités59# fusionnées (Saguenay, Gatineau, La Tuque…) tout en rejetant les rues60# homonymes d'une autre région (Saint-Augustin ↔ Jonquière = ~190 km).61# ⚠ Le Québec compte de nombreuses municipalités HOMONYMES (deux L'Ange-Gardien,62# deux Saint-Donat, deux Sainte-Félicité…) : la référence n'est donc jamais un63# centroïde unique mais un ENSEMBLE de grappes de fiches (chaque homonyme garde64# la sienne) + le point Nominatim de la ville.65VILLE_RADIUS_KM = 40.066CLUSTER_MIN = 3 # une grappe de fiches devient une référence de confiance dès n >= 3676869def _bbox_for(city: str) -> tuple[float, float, float, float]:70 key = strip_accents((city or "").strip().lower())71 return _BBOX_QUEBEC if key in _VILLES_QUEBEC else _BBOX_KA727374def _in_bbox(lat: float, lng: float, bbox: tuple) -> bool:75 return bbox[0] <= lat <= bbox[1] and bbox[2] <= lng <= bbox[3]767778def _dist_km(lat1: float, lng1: float, lat2: float, lng2: float) -> float:79 """Distance haversine en kilomètres."""80 p1, p2 = math.radians(lat1), math.radians(lat2)81 a = (math.sin((p2 - p1) / 2) ** 282 + math.cos(p1) * math.cos(p2) * math.sin(math.radians(lng2 - lng1) / 2) ** 2)83 return 2 * 6371.0 * math.asin(math.sqrt(a))848586def norm_key(address: str) -> str:87 """Clé de cache : adresse normalisée (casse, accents, espaces, n° d'app.)."""88 s = strip_accents((address or "").lower())89 s = re.sub(r"\b(?:app?t?|appartement|unite|suite|#)\.?\s*[\w-]+\b", " ", s)90 s = re.sub(r"[^a-z0-9]+", " ", s)91 return re.sub(r"\s+", " ", s).strip()929394class Geocoder:95 def __init__(self, con) -> None:96 self.con = con97 self.session = requests.Session()98 self.session.headers["User-Agent"] = USER_AGENT99 self._last = 0.0100 self._villes: dict[str, dict | None] = {} # norm(ville) -> {lat,lng,muni}101 self._med: dict[str, list[tuple]] | None = None # norm(ville) -> grappes (lat,lng,n)102 self.ville_api_calls = 0 # requêtes Nominatim « ville »103104 @staticmethod105 def _clean(address: str) -> str:106 """Nettoyages qui aident Nominatim sur les adresses québécoises."""107 s = address.strip()108 # « 101-2905 Rue X » = unité 101, civique 2905 -> garder le civique109 s = re.sub(r"^(\d+)-(\d+)\s", r"\2 ", s)110 # « 6275 et 6375 boulevard X » -> premier civique111 s = re.sub(r"^(\d+)\s+et\s+\d+\s", r"\1 ", s)112 # « Montréal - Laval » / « Montréal - Île-des-Soeurs » -> garder le vrai lieu113 s = re.sub(r"montr[ée]al\s*-\s*", "", s, flags=re.I)114 # « bureau 105 » / « suite 3 » / « local B » : suffixes de bureau115 s = re.sub(r",?\s*(?:bureau|suite|local|app\.?|apt\.?)\s*[\w-]+\b", "", s, flags=re.I)116 # « Vanier (Québec) » -> « Vanier, Québec »117 s = re.sub(r"\s*\((qu[ée]bec)\)", r", \1", s, flags=re.I)118 # abréviations cardinales : « Rue Salaberry O » -> « Ouest »119 s = re.sub(r"\bO\.?(?=,|\s*$)", "Ouest", s)120 s = re.sub(r"\bE\.?(?=,|\s*$)", "Est", s)121 return s122123 def _build_query(self, address: str, city: str) -> str:124 q = self._clean(address)125 key = strip_accents(q.lower())126 if city and strip_accents(city.lower()) not in key:127 q += f", {city}"128 if "quebec" not in strip_accents(q.lower()) and "qc" not in q.lower():129 q += ", Québec"130 return q + ", Canada"131132 def _query_nominatim(self, params: dict) -> tuple[float, float] | None:133 """Une requête Nominatim, throttlée."""134 wait = REQUEST_DELAY - (time.time() - self._last)135 if wait > 0:136 time.sleep(wait)137 try:138 resp = self.session.get(NOMINATIM_URL, params={139 "format": "jsonv2", "limit": 1, "countrycodes": "ca", **params,140 }, timeout=20)141 self._last = time.time()142 resp.raise_for_status()143 hits = resp.json()144 except Exception:145 self._last = time.time()146 return None147 if not hits:148 return None149 try:150 return float(hits[0]["lat"]), float(hits[0]["lon"])151 except (KeyError, ValueError):152 return None153154 def _query_adresses_quebec(self, address: str, city: str) -> tuple[float, float] | None:155 """Repli : géocodeur officiel Adresses Québec (MERN, ArcGIS).156157 Couvre les rues trop récentes pour OSM. Seuil de score AQ_MIN_SCORE158 pour éviter les correspondances approximatives sur une autre rue.159 """160 premier = self._clean(address).split(",")[0].strip()161 ville = (city or "Québec").strip()162 wait = REQUEST_DELAY_AQ - (time.time() - self._last)163 if wait > 0:164 time.sleep(wait)165 try:166 resp = self.session.get(AQ_URL, params={167 "SingleLine": f"{premier}, {ville}",168 "f": "json", "outSR": 4326, "maxLocations": 1,169 }, timeout=20)170 self._last = time.time()171 resp.raise_for_status()172 cands = resp.json().get("candidates") or []173 except Exception:174 self._last = time.time()175 return None176 if not cands or cands[0].get("score", 0) < AQ_MIN_SCORE:177 return None178 loc = cands[0].get("location") or {}179 try:180 return float(loc["y"]), float(loc["x"])181 except (KeyError, ValueError):182 return None183184 def _attempts(self, address: str, city: str, muni: str | None = None) -> list[dict]:185 """Stratégies de requête, de la plus précise à la moins précise.186187 1) structurée civique+rue (évite l'ambiguïté « Québec » ville/province)188 2) idem avec la municipalité OFFICIELLE (« Saguenay ») quand la ville189 annoncée est un secteur (« Jonquière (Lac-Kénogami) ») inconnu des API190 3) recherche libre complète191 4) structurée rue seule -> centroïde de rue (repli acceptable pour la192 carte quand le numéro civique est absent d'OpenStreetMap)193 """194 premier = self._clean(address).split(",")[0].strip()195 ville = (city or "").strip()196 if not ville:197 k = strip_accents(address.lower())198 ville = "Lévis" if "levis" in k else "Québec"199 villes = [ville]200 # premier mot de la ville (« Jonquiere Lac Kenogami » -> « Jonquiere »)201 if " " in ville:202 villes.append(ville.split(" ")[0])203 if muni and norm_key(muni) not in {norm_key(v) for v in villes}:204 villes.append(muni)205206 tries: list[dict] = []207 m = re.match(r"^(\d+)[,\s]+(.{4,})$", premier)208 for v in villes:209 commun = {"city": v, "state": "Québec", "country": "Canada"}210 if m:211 tries.append({"street": f"{m.group(1)} {m.group(2)}", **commun})212 tries.append({"q": self._build_query(address, city)})213 for v in villes:214 commun = {"city": v, "state": "Québec", "country": "Canada"}215 if m:216 tries.append({"street": m.group(2), **commun})217 return tries218219 # ----- références de ville (validation rigoureuse) -----------------------220 def _clusters(self) -> dict[str, list[tuple]]:221 """Grappes de coordonnées par ville, depuis les fiches déjà géocodées222 (regroupement glouton, rayon VILLE_RADIUS_KM). Chaque grappe :223 (lat médiane, lng médiane, n). Les municipalités homonymes produisent224 naturellement une grappe chacune — aucune n'écrase l'autre."""225 if self._med is None:226 acc: dict[str, list] = {}227 for r in self.con.execute(228 """SELECT city, lat, lng FROM listings229 WHERE active=1 AND lat IS NOT NULL AND city<>''"""):230 acc.setdefault(norm_key(r["city"]), []).append((r["lat"], r["lng"]))231 out: dict[str, list[tuple]] = {}232 for k, pts in acc.items():233 clusters: list[list] = [] # [ [lat_c, lng_c, [points]] ]234 for lat, lng in pts:235 for cl in clusters:236 if _dist_km(lat, lng, cl[0], cl[1]) <= VILLE_RADIUS_KM:237 cl[2].append((lat, lng))238 cl[0] = statistics.median(p[0] for p in cl[2])239 cl[1] = statistics.median(p[1] for p in cl[2])240 break241 else:242 clusters.append([lat, lng, [(lat, lng)]])243 out[k] = [(cl[0], cl[1], len(cl[2])) for cl in clusters]244 self._med = out245 return self._med246247 def resolve_ville(self, city: str, allow_api: bool = True) -> dict | None:248 """Ville -> {lat, lng, muni} (muni = municipalité officielle, ex.249 « Jonquière (Lac-Kénogami) » -> Saguenay). Cache : geocode_cache,250 clé « ville:<norm> ». None si irrésoluble."""251 k = norm_key(city or "")252 if not k:253 return None254 if k in self._villes:255 return self._villes[k]256 key = "ville:" + k257 row = self.con.execute(258 "SELECT lat, lng, muni, failed, ts FROM geocode_cache WHERE address=?",259 (key,)).fetchone()260 if row is not None:261 if not row["failed"]:262 v = {"lat": row["lat"], "lng": row["lng"], "muni": row["muni"]}263 self._villes[k] = v264 return v265 if time.time() - (row["ts"] or 0) < RETRY_FAILED_AFTER:266 self._villes[k] = None267 return None268 if not allow_api:269 return None # pas de mémoire : on réessaiera quand l'API sera permise270 v = None271 # priorité au Québec ; à défaut, l'Ontario (extension immo-ka)272 for prov in ("Québec", "Ontario"):273 self.ville_api_calls += 1274 wait = REQUEST_DELAY - (time.time() - self._last)275 if wait > 0:276 time.sleep(wait)277 try:278 resp = self.session.get(NOMINATIM_URL, params={279 "q": f"{city}, {prov}, Canada", "format": "jsonv2", "limit": 1,280 "countrycodes": "ca", "addressdetails": 1}, timeout=20)281 self._last = time.time()282 resp.raise_for_status()283 hits = resp.json()284 except Exception:285 self._last = time.time()286 return None # erreur réseau : ne pas cacher un échec définitif287 if hits:288 try:289 lat, lng = float(hits[0]["lat"]), float(hits[0]["lon"])290 if _in_bbox(lat, lng, _BBOX_KA):291 a = hits[0].get("address") or {}292 muni = (a.get("city") or a.get("town") or a.get("village")293 or a.get("municipality"))294 v = {"lat": lat, "lng": lng, "muni": muni}295 except (KeyError, ValueError, TypeError):296 v = None297 if v:298 break299 self.con.execute(300 "INSERT INTO geocode_cache (address, lat, lng, muni, provider, failed, ts)"301 " VALUES (?,?,?,?,?,?,?) ON CONFLICT(address) DO UPDATE SET"302 " lat=excluded.lat, lng=excluded.lng, muni=excluded.muni,"303 " provider=excluded.provider, failed=excluded.failed, ts=excluded.ts",304 (key, v["lat"] if v else None, v["lng"] if v else None,305 v["muni"] if v else None, "ville_nominatim", 0 if v else 1, time.time()))306 self.con.commit()307 self._villes[k] = v308 return v309310 def city_refs(self, city: str, allow_api: bool = True) -> list[tuple[float, float]]:311 """Points de référence d'une ville : grappes de fiches dignes de312 confiance (n >= CLUSTER_MIN — une par municipalité homonyme) + point313 Nominatim de la ville. Liste vide = ville inconnue (-> bbox seule)."""314 k = norm_key(city or "")315 if not k:316 return []317 refs = [(cl[0], cl[1]) for cl in self._clusters().get(k, [])318 if cl[2] >= CLUSTER_MIN]319 v = self.resolve_ville(city, allow_api=allow_api)320 if v:321 refs.append((v["lat"], v["lng"]))322 return refs323324 def coords_ok(self, c: tuple[float, float] | None, city: str,325 refs: list[tuple[float, float]] | None = None) -> bool:326 """Valide un candidat : dans la bbox ET à <= VILLE_RADIUS_KM d'au moins327 une référence de la ville annoncée (quand elle en a)."""328 if c is None or not _in_bbox(*c, _bbox_for(city)):329 return False330 if refs is None:331 refs = self.city_refs(city)332 return not refs or any(_dist_km(c[0], c[1], r[0], r[1]) <= VILLE_RADIUS_KM333 for r in refs)334335 def resolve(self, address: str, city: str) -> tuple[float, float] | None:336 """Adresse -> (lat, lng), via cache puis AQ/Nominatim. None si introuvable.337338 Rigueur : chaque candidat doit être cohérent avec la ville annoncée339 (centroïde <= VILLE_RADIUS_KM). Si la ville annoncée est un secteur340 inconnu d'Adresses Québec, on réessaie avec la municipalité officielle.341 """342 key = norm_key(address)343 if not key or len(key) < 6:344 return None345 row = self.con.execute(346 "SELECT lat, lng, failed, ts FROM geocode_cache WHERE address=?",347 (key,)).fetchone()348 if row is not None:349 if not row["failed"]:350 return (row["lat"], row["lng"])351 if time.time() - (row["ts"] or 0) < RETRY_FAILED_AFTER:352 return None # échec récent : ne pas marteler l'API353354 refs = self.city_refs(city)355 coords = None356 # Adresses Québec (MERN) d'abord : rapide, autoritatif au Québec, pas de357 # limite stricte. Nominatim (1 req/s) seulement en repli.358 provider = "adresses_quebec"359 c = self._query_adresses_quebec(address, city)360 if self.coords_ok(c, city, refs):361 coords = c362 muni = None363 if coords is None:364 # ville annoncée = secteur (« Jonquière (Lac-Kénogami) ») ? AQ ne365 # connaît que les municipalités officielles -> retenter avec elle.366 v = self.resolve_ville(city)367 muni = (v or {}).get("muni")368 if muni and norm_key(muni) != norm_key(city or ""):369 c = self._query_adresses_quebec(address, muni)370 if self.coords_ok(c, city, refs):371 coords, provider = c, "adresses_quebec_muni"372 if coords is None:373 provider = "nominatim"374 for params in self._attempts(address, city, muni):375 c = self._query_nominatim(params)376 if self.coords_ok(c, city, refs):377 coords = c378 break379 ok = coords is not None380 self.con.execute(381 "INSERT INTO geocode_cache (address, lat, lng, provider, failed, ts)"382 " VALUES (?,?,?,?,?,?)"383 " ON CONFLICT(address) DO UPDATE SET lat=excluded.lat,"384 " lng=excluded.lng, provider=excluded.provider,"385 " failed=excluded.failed, ts=excluded.ts",386 (key, coords[0] if ok else None, coords[1] if ok else None,387 provider, 0 if ok else 1, time.time()))388 self.con.commit()389 return coords if ok else None390391392def run(limit: int | None = None) -> dict:393 """Géocode les annonces actives sans coordonnées, par adresse unique.394395 `limit` borne le nombre de NOUVELLES requêtes Nominatim (les hits de396 cache sont gratuits et toujours appliqués).397 """398 con = db.connect()399 geo = Geocoder(con)400 rows = con.execute(401 """SELECT uid, address, city FROM listings402 WHERE active=1 AND lat IS NULL AND address<>'' AND geocode_failed=0403 AND dup_hidden=0404 ORDER BY address""").fetchall()405406 # regrouper par immeuble (adresse normalisée)407 groupes: dict[str, list] = {}408 for r in rows:409 groupes.setdefault(norm_key(r["address"]), []).append(r)410411 done = failed = requests_made = 0412 for key, members in groupes.items():413 if not key:414 continue415 cached = con.execute(416 "SELECT failed FROM geocode_cache WHERE address=?", (key,)).fetchone()417 if cached is None:418 if limit is not None and requests_made >= limit:419 continue420 requests_made += 1421 try:422 coords = geo.resolve(members[0]["address"], members[0]["city"])423 if coords:424 for r in members:425 con.execute("UPDATE listings SET lat=?, lng=? WHERE uid=?",426 (coords[0], coords[1], r["uid"]))427 done += len(members)428 else:429 # introuvable/hors zone : flag ; jamais de coordonnées bidon430 for r in members:431 con.execute("UPDATE listings SET geocode_failed=1 WHERE uid=?",432 (r["uid"],))433 failed += len(members)434 con.commit()435 except sqlite3.OperationalError:436 # verrou transitoire (watcher/refresh_dedup) : on saute cette adresse,437 # elle sera reprise au prochain passage — jamais crasher tout le run.438 try:439 con.rollback()440 except sqlite3.Error:441 pass442 time.sleep(1.0)443 continue444445 con.close()446 stats = {"geocoded": done, "failed": failed,447 "unique_addresses": len(groupes), "api_requests": requests_made}448 print(f"[immo-ka] geocode {stats}")449 return stats450451452AQ_BATCH_URL = ("https://servicescarto.mern.gouv.qc.ca/pes/rest/services/Territoire/"453 "Adresse_Geocodage/GeocodeServer/geocodeAddresses")454BATCH_SIZE = 200455RESCUE_MAX = 150 # rattrapages resolve() max par passe (Nominatim = 1 req/s)456457458def strip_bad_source_coords(con, listings) -> int:459 """Garde d'ingestion : annule les lat/lng fournis par un connecteur quand460 ils contredisent la ville annoncée (> VILLE_RADIUS_KM du centroïde connu).461 Cache seulement (médianes + villes déjà résolues) : aucun appel réseau,462 la synchronisation reste rapide. Retourne le nombre de paires annulées."""463 geo = Geocoder(con)464 n = 0465 for lst in listings:466 if lst.lat is None or lst.lng is None or not (lst.city or "").strip():467 continue468 refs = geo.city_refs(lst.city, allow_api=False)469 if refs and not geo.coords_ok((lst.lat, lst.lng), lst.city, refs):470 lst.lat = lst.lng = None471 n += 1472 return n473474475def run_batch(limit: int | None = None) -> dict:476 """Géocodage EN LOT via Adresses Québec (`geocodeAddresses`, jusqu'à 1000477 adresses/requête) — ~35 requêtes pour tout le parc au lieu de dizaines de478 milliers. Beaucoup plus rapide que le mode 1-par-1 (et que Nominatim)."""479 con = db.connect()480 geo = Geocoder(con) # pour réutiliser _clean / bbox481 rows = con.execute(482 """SELECT uid, address, city FROM listings483 WHERE active=1 AND lat IS NULL AND address<>'' AND geocode_failed=0484 AND dup_hidden=0 AND COALESCE(region,'')<>'Ontario'485 ORDER BY address""").fetchall()486 # 1 entrée par immeuble (adresse normalisée) ; on saute les échecs en cache487 uniq: dict[str, dict] = {}488 for r in rows:489 k = norm_key(r["address"])490 if not k or len(k) < 6:491 continue492 u = uniq.setdefault(k, {"address": r["address"], "city": r["city"],493 "members": []})494 u["members"].append(r["uid"])495 pending = []496 for k, u in uniq.items():497 c = con.execute("SELECT lat,lng,failed FROM geocode_cache WHERE address=?",498 (k,)).fetchone()499 if c is not None and not c["failed"]: # déjà résolu : appliquer direct500 for uid in u["members"]:501 con.execute("UPDATE listings SET lat=?, lng=? WHERE uid=?",502 (c["lat"], c["lng"], uid))503 continue504 if c is not None and c["failed"]:505 continue506 pending.append((k, u))507 con.commit()508 if limit is not None:509 pending = pending[:limit]510511 session = requests.Session()512 session.headers["User-Agent"] = USER_AGENT513 done = failed = 0514 rescue: list[tuple[str, dict]] = [] # échecs du lot -> resolve() individuel515 for i in range(0, len(pending), BATCH_SIZE):516 chunk = pending[i:i + BATCH_SIZE]517 records = {"records": [518 {"attributes": {"OBJECTID": j,519 "SingleLine": f"{geo._clean(u['address']).split(',')[0].strip()}, "520 f"{(u['city'] or 'Québec').strip()}"}}521 for j, (_k, u) in enumerate(chunk)]}522 try:523 # POST obligatoire : le param `addresses` (JSON de N records) est trop524 # long pour une URL GET dès quelques dizaines d'adresses.525 resp = session.post(AQ_BATCH_URL, data={526 "addresses": json.dumps(records, ensure_ascii=False),527 "f": "json", "outSR": 4326}, timeout=90)528 locs = resp.json().get("locations", [])529 except Exception as e:530 print(f"[immo-ka] geocode-batch lot {i//BATCH_SIZE} ERREUR: {str(e)[:80]}")531 time.sleep(1.0)532 continue533 by_id = {l["attributes"].get("ResultID"): l for l in locs}534 for j, (k, u) in enumerate(chunk):535 loc = by_id.get(j)536 coords = None537 if loc and loc["attributes"].get("Score", 0) >= AQ_MIN_SCORE:538 lc = loc.get("location") or {}539 try:540 cand = (float(lc["y"]), float(lc["x"]))541 # bbox + cohérence avec le centroïde de la ville annoncée542 if geo.coords_ok(cand, u["city"]):543 coords = cand544 except (KeyError, ValueError, TypeError):545 coords = None546 try:547 if coords:548 for uid in u["members"]:549 con.execute("UPDATE listings SET lat=?, lng=? WHERE uid=?",550 (coords[0], coords[1], uid))551 con.execute(552 "INSERT INTO geocode_cache (address,lat,lng,provider,failed,ts)"553 " VALUES (?,?,?,?,0,?) ON CONFLICT(address) DO UPDATE SET"554 " lat=excluded.lat, lng=excluded.lng, provider=excluded.provider,"555 " failed=0, ts=excluded.ts",556 (k, coords[0], coords[1], "adresses_quebec_batch", time.time()))557 done += len(u["members"])558 con.commit()559 else:560 # pas de verdict tout de suite : resolve() réessaiera avec la561 # municipalité officielle + Nominatim (borné par RESCUE_MAX)562 rescue.append((k, u))563 except sqlite3.OperationalError:564 try: con.rollback()565 except sqlite3.Error: pass566 time.sleep(1.0)567 print(f"[immo-ka] geocode-batch {i+len(chunk)}/{len(pending)} "568 f"(résolues {done}, à rattraper {len(rescue)})")569570 # Rattrapage 1-par-1 des échecs du lot : variantes de municipalité (AQ) puis571 # Nominatim, validées par centroïde. resolve() écrit lui-même le verdict au572 # cache (succès ou échec 30 j) ; au-delà de RESCUE_MAX, on laisse les fiches573 # intactes (ni cache ni flag) -> reprises aux prochains passages.574 rescued = 0575 for k, u in rescue[:RESCUE_MAX]:576 try:577 coords = geo.resolve(u["address"], u["city"])578 if coords:579 for uid in u["members"]:580 con.execute("UPDATE listings SET lat=?, lng=? WHERE uid=?",581 (coords[0], coords[1], uid))582 done += len(u["members"])583 rescued += len(u["members"])584 else:585 for uid in u["members"]:586 con.execute("UPDATE listings SET geocode_failed=1 WHERE uid=?", (uid,))587 failed += len(u["members"])588 con.commit()589 except sqlite3.OperationalError:590 try: con.rollback()591 except sqlite3.Error: pass592 time.sleep(1.0)593 con.close()594 stats = {"geocoded": done, "rescued": rescued, "failed": failed,595 "deferred": max(0, len(rescue) - RESCUE_MAX),596 "batches": (len(pending)+BATCH_SIZE-1)//BATCH_SIZE}597 print(f"[immo-ka] geocode-batch {stats}")598 return stats599600601def run_audit(nominatim_budget: int | None = None, apply: bool = True) -> dict:602 """Audit de cohérence géographique des fiches DÉJÀ géocodées.603604 Détecte les coordonnées à plus de VILLE_RADIUS_KM de TOUTE référence de605 leur ville — grappes de fiches (une par municipalité homonyme) + point606 Nominatim — puis les remet en file de géocodage : lat/lng annulés,607 geocode_failed remis à 0, entrée de cache purgée. Une fiche n'est JAMAIS608 flaguée sans référence indépendante confirmant l'incohérence (les609 homonymes et villes irrésolues sont épargnés). `nominatim_budget` borne610 les résolutions de villes inconnues (1 req/s) ; None = illimité.611 `apply=False` = rapport seul.612 """613 con = db.connect()614 geo = Geocoder(con)615 rows = con.execute(616 """SELECT uid, address, city, lat, lng FROM listings617 WHERE active=1 AND lat IS NOT NULL AND city<>''""").fetchall()618 flagged, inconnues, reportees = [], 0, 0619 for r in rows:620 k = norm_key(r["city"])621 clusters = [(cl[0], cl[1]) for cl in geo._clusters().get(k, [])622 if cl[2] >= CLUSTER_MIN]623 dists = [_dist_km(r["lat"], r["lng"], c[0], c[1]) for c in clusters]624 if dists and min(dists) <= VILLE_RADIUS_KM:625 continue # cohérente avec une grappe de sa ville626 budget_ok = nominatim_budget is None or geo.ville_api_calls < nominatim_budget627 v = geo.resolve_ville(r["city"], allow_api=budget_ok)628 if v is not None:629 d = _dist_km(r["lat"], r["lng"], v["lat"], v["lng"])630 if d <= VILLE_RADIUS_KM:631 continue # cohérente avec le point Nominatim de la ville632 flagged.append((r, min(dists + [d])))633 elif not clusters:634 inconnues += 1 # aucune référence : on ne flague pas635 elif not budget_ok:636 reportees += 1 # ville pas encore résolue : prochaine passe637 else:638 flagged.append((r, min(dists))) # ville irrésoluble, grappes loin639 if apply:640 for r, d in flagged:641 con.execute("DELETE FROM geocode_cache WHERE address=?",642 (norm_key(r["address"]),))643 con.execute("""UPDATE listings SET lat=NULL, lng=NULL,644 geocode_failed=0 WHERE uid=?""", (r["uid"],))645 con.commit()646 for r, d in flagged[:20]:647 print(f"[immo-ka] audit: {r['uid']} « {r['address']}, {r['city']} » "648 f"à {d:.0f} km de sa ville")649 con.close()650 stats = {"verifiees": len(rows), "incoherentes": len(flagged),651 "villes_inconnues": inconnues, "reportees": reportees,652 "corrigees": apply}653 print(f"[immo-ka] geocode-audit {stats}")654 return stats655