SPB Git

spb/ora-ka Public

Ora-Ka — cinq agrégateurs Ka, une barre de recherche hybride (exact + sémantique)

Python 80% TypeScript 12.9% CSS 6.8%
16.4 KB · 383 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# -----------------------------------------------------------------------------12from __future__ import annotations1314import json15import sqlite31617import re18import time1920import requests2122from . import db23from .normalize import strip_accents2425NOMINATIM_URL = "https://nominatim.openstreetmap.org/search"26# Repli officiel du gouvernement du Québec (Adresses Québec / MERN) : couvre27# les rues trop récentes pour OpenStreetMap (développements neufs de Lévis…)28AQ_URL = ("https://servicescarto.mern.gouv.qc.ca/pes/rest/services/Territoire/"29          "Adresse_Geocodage/GeocodeServer/findAddressCandidates")30AQ_MIN_SCORE = 7531# ASCII pur : le serveur ArcGIS d'Adresses Québec retourne 500 si l'en-tête32# User-Agent contient des accents (latin-1)33USER_AGENT = "ImmoKaBot/1.0 (agregateur logements Quebec; +contact@spboucher.ai)"34REQUEST_DELAY = 1.1          # règle Nominatim : max 1 req/s35REQUEST_DELAY_AQ = 0.12      # Adresses Québec (MERN) : pas de limite stricte36RETRY_FAILED_AFTER = 30 * 86400   # re-tenter les échecs après 30 jours3738# Bounding boxes (lat_min, lat_max, lng_min, lng_max)39_BBOX_QUEBEC = (46.55, 47.10, -71.75, -70.85)     # Québec, Lévis, St-Augustin40# garde-fou provincial : couvre Gatineau, l'Abitibi, le Saguenay, la Côte-Nord41# et la Gaspésie (expansion provinciale) — même boîte que schema.finalize()42_BBOX_SUD_QC = (44.50, 63.00, -80.00, -56.00)4344_VILLES_QUEBEC = {"quebec", "levis", "saint-augustin-de-desmaures",45                  "l'ancienne-lorette", "ancienne-lorette"}464748def _bbox_for(city: str) -> tuple[float, float, float, float]:49    key = strip_accents((city or "").strip().lower())50    return _BBOX_QUEBEC if key in _VILLES_QUEBEC else _BBOX_SUD_QC515253def _in_bbox(lat: float, lng: float, bbox: tuple) -> bool:54    return bbox[0] <= lat <= bbox[1] and bbox[2] <= lng <= bbox[3]555657def norm_key(address: str) -> str:58    """Clé de cache : adresse normalisée (casse, accents, espaces, n° d'app.)."""59    s = strip_accents((address or "").lower())60    s = re.sub(r"\b(?:app?t?|appartement|unite|suite|#)\.?\s*[\w-]+\b", " ", s)61    s = re.sub(r"[^a-z0-9]+", " ", s)62    return re.sub(r"\s+", " ", s).strip()636465class Geocoder:66    def __init__(self, con) -> None:67        self.con = con68        self.session = requests.Session()69        self.session.headers["User-Agent"] = USER_AGENT70        self._last = 0.07172    @staticmethod73    def _clean(address: str) -> str:74        """Nettoyages qui aident Nominatim sur les adresses québécoises."""75        s = address.strip()76        # « 101-2905 Rue X » = unité 101, civique 2905 -> garder le civique77        s = re.sub(r"^(\d+)-(\d+)\s", r"\2 ", s)78        # « 6275 et 6375 boulevard X » -> premier civique79        s = re.sub(r"^(\d+)\s+et\s+\d+\s", r"\1 ", s)80        # « Montréal - Laval » / « Montréal - Île-des-Soeurs » -> garder le vrai lieu81        s = re.sub(r"montr[ée]al\s*-\s*", "", s, flags=re.I)82        # « bureau 105 » / « suite 3 » / « local B » : suffixes de bureau83        s = re.sub(r",?\s*(?:bureau|suite|local|app\.?|apt\.?)\s*[\w-]+\b", "", s, flags=re.I)84        # « Vanier (Québec) » -> « Vanier, Québec »85        s = re.sub(r"\s*\((qu[ée]bec)\)", r", \1", s, flags=re.I)86        # abréviations cardinales : « Rue Salaberry O » -> « Ouest »87        s = re.sub(r"\bO\.?(?=,|\s*$)", "Ouest", s)88        s = re.sub(r"\bE\.?(?=,|\s*$)", "Est", s)89        return s9091    def _build_query(self, address: str, city: str) -> str:92        q = self._clean(address)93        key = strip_accents(q.lower())94        if city and strip_accents(city.lower()) not in key:95            q += f", {city}"96        if "quebec" not in strip_accents(q.lower()) and "qc" not in q.lower():97            q += ", Québec"98        return q + ", Canada"99100    def _query_nominatim(self, params: dict) -> tuple[float, float] | None:101        """Une requête Nominatim, throttlée."""102        wait = REQUEST_DELAY - (time.time() - self._last)103        if wait > 0:104            time.sleep(wait)105        try:106            resp = self.session.get(NOMINATIM_URL, params={107                "format": "jsonv2", "limit": 1, "countrycodes": "ca", **params,108            }, timeout=20)109            self._last = time.time()110            resp.raise_for_status()111            hits = resp.json()112        except Exception:113            self._last = time.time()114            return None115        if not hits:116            return None117        try:118            return float(hits[0]["lat"]), float(hits[0]["lon"])119        except (KeyError, ValueError):120            return None121122    def _query_adresses_quebec(self, address: str, city: str) -> tuple[float, float] | None:123        """Repli : géocodeur officiel Adresses Québec (MERN, ArcGIS).124125        Couvre les rues trop récentes pour OSM. Seuil de score AQ_MIN_SCORE126        pour éviter les correspondances approximatives sur une autre rue.127        """128        premier = self._clean(address).split(",")[0].strip()129        ville = (city or "Québec").strip()130        wait = REQUEST_DELAY_AQ - (time.time() - self._last)131        if wait > 0:132            time.sleep(wait)133        try:134            resp = self.session.get(AQ_URL, params={135                "SingleLine": f"{premier}, {ville}",136                "f": "json", "outSR": 4326, "maxLocations": 1,137            }, timeout=20)138            self._last = time.time()139            resp.raise_for_status()140            cands = resp.json().get("candidates") or []141        except Exception:142            self._last = time.time()143            return None144        if not cands or cands[0].get("score", 0) < AQ_MIN_SCORE:145            return None146        loc = cands[0].get("location") or {}147        try:148            return float(loc["y"]), float(loc["x"])149        except (KeyError, ValueError):150            return None151152    def _attempts(self, address: str, city: str) -> list[dict]:153        """Stratégies de requête, de la plus précise à la moins précise.154155        1) structurée civique+rue (évite l'ambiguïté « Québec » ville/province)156        2) recherche libre complète157        3) structurée rue seule -> centroïde de rue (repli acceptable pour la158           carte quand le numéro civique est absent d'OpenStreetMap)159        """160        premier = self._clean(address).split(",")[0].strip()161        ville = (city or "").strip()162        if not ville:163            k = strip_accents(address.lower())164            ville = "Lévis" if "levis" in k else "Québec"165        commun = {"city": ville, "state": "Québec", "country": "Canada"}166167        tries: list[dict] = []168        m = re.match(r"^(\d+)[,\s]+(.{4,})$", premier)169        if m:170            tries.append({"street": f"{m.group(1)} {m.group(2)}", **commun})171        tries.append({"q": self._build_query(address, city)})172        if m:173            tries.append({"street": m.group(2), **commun})174        return tries175176    def resolve(self, address: str, city: str) -> tuple[float, float] | None:177        """Adresse -> (lat, lng), via cache puis Nominatim. None si introuvable."""178        key = norm_key(address)179        if not key or len(key) < 6:180            return None181        row = self.con.execute(182            "SELECT lat, lng, failed, ts FROM geocode_cache WHERE address=?",183            (key,)).fetchone()184        if row is not None:185            if not row["failed"]:186                return (row["lat"], row["lng"])187            if time.time() - (row["ts"] or 0) < RETRY_FAILED_AFTER:188                return None   # échec récent : ne pas marteler l'API189190        bbox = _bbox_for(city)191        coords = None192        # Adresses Québec (MERN) d'abord : rapide, autoritatif au Québec, pas de193        # limite stricte. Nominatim (1 req/s) seulement en repli.194        provider = "adresses_quebec"195        c = self._query_adresses_quebec(address, city)196        if c is not None and _in_bbox(*c, bbox):197            coords = c198        if coords is None:199            provider = "nominatim"200            for params in self._attempts(address, city):201                c = self._query_nominatim(params)202                if c is not None and _in_bbox(*c, bbox):203                    coords = c204                    break205        ok = coords is not None206        self.con.execute(207            "INSERT INTO geocode_cache (address, lat, lng, provider, failed, ts)"208            " VALUES (?,?,?,?,?,?)"209            " ON CONFLICT(address) DO UPDATE SET lat=excluded.lat,"210            " lng=excluded.lng, provider=excluded.provider,"211            " failed=excluded.failed, ts=excluded.ts",212            (key, coords[0] if ok else None, coords[1] if ok else None,213             provider, 0 if ok else 1, time.time()))214        self.con.commit()215        return coords if ok else None216217218def run(limit: int | None = None) -> dict:219    """Géocode les annonces actives sans coordonnées, par adresse unique.220221    `limit` borne le nombre de NOUVELLES requêtes Nominatim (les hits de222    cache sont gratuits et toujours appliqués).223    """224    con = db.connect()225    geo = Geocoder(con)226    rows = con.execute(227        """SELECT uid, address, city FROM listings228           WHERE active=1 AND lat IS NULL AND address<>'' AND geocode_failed=0229             AND dup_hidden=0230           ORDER BY address""").fetchall()231232    # regrouper par immeuble (adresse normalisée)233    groupes: dict[str, list] = {}234    for r in rows:235        groupes.setdefault(norm_key(r["address"]), []).append(r)236237    done = failed = requests_made = 0238    for key, members in groupes.items():239        if not key:240            continue241        cached = con.execute(242            "SELECT failed FROM geocode_cache WHERE address=?", (key,)).fetchone()243        if cached is None:244            if limit is not None and requests_made >= limit:245                continue246            requests_made += 1247        try:248            coords = geo.resolve(members[0]["address"], members[0]["city"])249            if coords:250                for r in members:251                    con.execute("UPDATE listings SET lat=?, lng=? WHERE uid=?",252                                (coords[0], coords[1], r["uid"]))253                done += len(members)254            else:255                # introuvable/hors zone : flag ; jamais de coordonnées bidon256                for r in members:257                    con.execute("UPDATE listings SET geocode_failed=1 WHERE uid=?",258                                (r["uid"],))259                failed += len(members)260            con.commit()261        except sqlite3.OperationalError:262            # verrou transitoire (watcher/refresh_dedup) : on saute cette adresse,263            # elle sera reprise au prochain passage — jamais crasher tout le run.264            try:265                con.rollback()266            except sqlite3.Error:267                pass268            time.sleep(1.0)269            continue270271    con.close()272    stats = {"geocoded": done, "failed": failed,273             "unique_addresses": len(groupes), "api_requests": requests_made}274    print(f"[immo-ka] geocode {stats}")275    return stats276277278AQ_BATCH_URL = ("https://servicescarto.mern.gouv.qc.ca/pes/rest/services/Territoire/"279                "Adresse_Geocodage/GeocodeServer/geocodeAddresses")280BATCH_SIZE = 200281282283def run_batch(limit: int | None = None) -> dict:284    """Géocodage EN LOT via Adresses Québec (`geocodeAddresses`, jusqu'à 1000285    adresses/requête) — ~35 requêtes pour tout le parc au lieu de dizaines de286    milliers. Beaucoup plus rapide que le mode 1-par-1 (et que Nominatim)."""287    con = db.connect()288    geo = Geocoder(con)     # pour réutiliser _clean / bbox289    rows = con.execute(290        """SELECT uid, address, city FROM listings291           WHERE active=1 AND lat IS NULL AND address<>'' AND geocode_failed=0292             AND dup_hidden=0 ORDER BY address""").fetchall()293    # 1 entrée par immeuble (adresse normalisée) ; on saute les échecs en cache294    uniq: dict[str, dict] = {}295    for r in rows:296        k = norm_key(r["address"])297        if not k or len(k) < 6:298            continue299        u = uniq.setdefault(k, {"address": r["address"], "city": r["city"],300                                "members": []})301        u["members"].append(r["uid"])302    pending = []303    for k, u in uniq.items():304        c = con.execute("SELECT lat,lng,failed FROM geocode_cache WHERE address=?",305                        (k,)).fetchone()306        if c is not None and not c["failed"]:      # déjà résolu : appliquer direct307            for uid in u["members"]:308                con.execute("UPDATE listings SET lat=?, lng=? WHERE uid=?",309                            (c["lat"], c["lng"], uid))310            continue311        if c is not None and c["failed"]:312            continue313        pending.append((k, u))314    con.commit()315    if limit is not None:316        pending = pending[:limit]317318    session = requests.Session()319    session.headers["User-Agent"] = USER_AGENT320    done = failed = 0321    for i in range(0, len(pending), BATCH_SIZE):322        chunk = pending[i:i + BATCH_SIZE]323        records = {"records": [324            {"attributes": {"OBJECTID": j,325                            "SingleLine": f"{geo._clean(u['address']).split(',')[0].strip()}, "326                                          f"{(u['city'] or 'Québec').strip()}"}}327            for j, (_k, u) in enumerate(chunk)]}328        try:329            # POST obligatoire : le param `addresses` (JSON de N records) est trop330            # long pour une URL GET dès quelques dizaines d'adresses.331            resp = session.post(AQ_BATCH_URL, data={332                "addresses": json.dumps(records, ensure_ascii=False),333                "f": "json", "outSR": 4326}, timeout=90)334            locs = resp.json().get("locations", [])335        except Exception as e:336            print(f"[immo-ka] geocode-batch lot {i//BATCH_SIZE} ERREUR: {str(e)[:80]}")337            time.sleep(1.0)338            continue339        by_id = {l["attributes"].get("ResultID"): l for l in locs}340        for j, (k, u) in enumerate(chunk):341            loc = by_id.get(j)342            coords = None343            if loc and loc["attributes"].get("Score", 0) >= AQ_MIN_SCORE:344                lc = loc.get("location") or {}345                try:346                    cand = (float(lc["y"]), float(lc["x"]))347                    if _in_bbox(*cand, _bbox_for(u["city"])):348                        coords = cand349                except (KeyError, ValueError, TypeError):350                    coords = None351            try:352                if coords:353                    for uid in u["members"]:354                        con.execute("UPDATE listings SET lat=?, lng=? WHERE uid=?",355                                    (coords[0], coords[1], uid))356                    con.execute(357                        "INSERT INTO geocode_cache (address,lat,lng,provider,failed,ts)"358                        " VALUES (?,?,?,?,0,?) ON CONFLICT(address) DO UPDATE SET"359                        " lat=excluded.lat, lng=excluded.lng, provider=excluded.provider,"360                        " failed=0, ts=excluded.ts",361                        (k, coords[0], coords[1], "adresses_quebec_batch", time.time()))362                    done += len(u["members"])363                else:364                    for uid in u["members"]:365                        con.execute("UPDATE listings SET geocode_failed=1 WHERE uid=?", (uid,))366                    con.execute(367                        "INSERT INTO geocode_cache (address,lat,lng,provider,failed,ts)"368                        " VALUES (?,?,?,?,1,?) ON CONFLICT(address) DO UPDATE SET"369                        " failed=1, ts=excluded.ts",370                        (k, None, None, "adresses_quebec_batch", time.time()))371                    failed += len(u["members"])372                con.commit()373            except sqlite3.OperationalError:374                try: con.rollback()375                except sqlite3.Error: pass376                time.sleep(1.0)377        print(f"[immo-ka] geocode-batch {i+len(chunk)}/{len(pending)} "378              f"(résolues {done}, échecs {failed})")379    con.close()380    stats = {"geocoded": done, "failed": failed, "batches": (len(pending)+BATCH_SIZE-1)//BATCH_SIZE}381    print(f"[immo-ka] geocode-batch {stats}")382    return stats383