SPB Git forge

spb/house-ka

Public
18commits 1branches 0releases
1.9 MBsize
maindefault branch
19 days agolast push
Python 67% TypeScript 18.2% CSS 14.4%
29.8 KB · 655 lines python
Raw Blame History
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