SPB Git

spb/lou-ka Public

Lou·Ka — tous les logements à louer du Québec, un seul endroit.

HTML 99.7%
10.7 KB · 259 lines python
Raw Blame History
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