Géocodage : repli Adresses Québec (MERN) + nettoyage « bureau »/« (Québec) »
Les rues trop récentes pour OpenStreetMap (Courchevel, L'Amont, Étienne- Dallaire — développements neufs de Lévis) sont résolues par le géocodeur officiel du gouvernement (ArcGIS findAddressCandidates, seuil de score 75). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Showing 1 changed file with +46 and −1
modified
louka/geocode.py
+46 −1
@@ -20,6 +20,11 @@ from . import db | ||
| 20 | 20 | from .normalize import strip_accents |
| 21 | 21 | |
| 22 | 22 | NOMINATIM_URL = "https://nominatim.openstreetmap.org/search" |
| 23 | +# Repli officiel du gouvernement du Québec (Adresses Québec / MERN) : couvre | |
| 24 | +# les rues trop récentes pour OpenStreetMap (développements neufs de Lévis…) | |
| 25 | +AQ_URL = ("https://servicescarto.mern.gouv.qc.ca/pes/rest/services/Territoire/" | |
| 26 | + "Adresse_Geocodage/GeocodeServer/findAddressCandidates") | |
| 27 | +AQ_MIN_SCORE = 75 | |
| 23 | 28 | USER_AGENT = "LouKaBot/1.0 (agrégateur logements Québec; +contact@spboucher.ai)" |
| 24 | 29 | REQUEST_DELAY = 1.1 # règle Nominatim : max 1 req/s |
| 25 | 30 | RETRY_FAILED_AFTER = 30 * 86400 # re-tenter les échecs après 30 jours |
@@ -66,6 +71,10 @@ class Geocoder: | ||
| 66 | 71 | s = re.sub(r"^(\d+)\s+et\s+\d+\s", r"\1 ", s) |
| 67 | 72 | # « Montréal - Laval » / « Montréal - Île-des-Soeurs » -> garder le vrai lieu |
| 68 | 73 | s = re.sub(r"montr[ée]al\s*-\s*", "", s, flags=re.I) |
| 74 | + # « bureau 105 » / « suite 3 » / « local B » : suffixes de bureau | |
| 75 | + s = re.sub(r",?\s*(?:bureau|suite|local|app\.?|apt\.?)\s*[\w-]+\b", "", s, flags=re.I) | |
| 76 | + # « Vanier (Québec) » -> « Vanier, Québec » | |
| 77 | + s = re.sub(r"\s*\((qu[ée]bec)\)", r", \1", s, flags=re.I) | |
| 69 | 78 | # abréviations cardinales : « Rue Salaberry O » -> « Ouest » |
| 70 | 79 | s = re.sub(r"\bO\.?(?=,|\s*$)", "Ouest", s) |
| 71 | 80 | s = re.sub(r"\bE\.?(?=,|\s*$)", "Est", s) |
@@ -102,6 +111,36 @@ class Geocoder: | ||
| 102 | 111 | except (KeyError, ValueError): |
| 103 | 112 | return None |
| 104 | 113 | |
| 114 | + def _query_adresses_quebec(self, address: str, city: str) -> tuple[float, float] | None: | |
| 115 | + """Repli : géocodeur officiel Adresses Québec (MERN, ArcGIS). | |
| 116 | + | |
| 117 | + Couvre les rues trop récentes pour OSM. Seuil de score AQ_MIN_SCORE | |
| 118 | + pour éviter les correspondances approximatives sur une autre rue. | |
| 119 | + """ | |
| 120 | + premier = self._clean(address).split(",")[0].strip() | |
| 121 | + ville = (city or "Québec").strip() | |
| 122 | + wait = REQUEST_DELAY - (time.time() - self._last) | |
| 123 | + if wait > 0: | |
| 124 | + time.sleep(wait) | |
| 125 | + try: | |
| 126 | + resp = self.session.get(AQ_URL, params={ | |
| 127 | + "SingleLine": f"{premier}, {ville}", | |
| 128 | + "f": "json", "outSR": 4326, "maxLocations": 1, | |
| 129 | + }, timeout=20) | |
| 130 | + self._last = time.time() | |
| 131 | + resp.raise_for_status() | |
| 132 | + cands = resp.json().get("candidates") or [] | |
| 133 | + except Exception: | |
| 134 | + self._last = time.time() | |
| 135 | + return None | |
| 136 | + if not cands or cands[0].get("score", 0) < AQ_MIN_SCORE: | |
| 137 | + return None | |
| 138 | + loc = cands[0].get("location") or {} | |
| 139 | + try: | |
| 140 | + return float(loc["y"]), float(loc["x"]) | |
| 141 | + except (KeyError, ValueError): | |
| 142 | + return None | |
| 143 | + | |
| 105 | 144 | def _attempts(self, address: str, city: str) -> list[dict]: |
| 106 | 145 | """Stratégies de requête, de la plus précise à la moins précise. |
| 107 | 146 | |
@@ -142,11 +181,17 @@ class Geocoder: | ||
| 142 | 181 | |
| 143 | 182 | bbox = _bbox_for(city) |
| 144 | 183 | coords = None |
| 184 | + provider = "nominatim" | |
| 145 | 185 | for params in self._attempts(address, city): |
| 146 | 186 | c = self._query_nominatim(params) |
| 147 | 187 | if c is not None and _in_bbox(*c, bbox): |
| 148 | 188 | coords = c |
| 149 | 189 | break |
| 190 | + if coords is None: | |
| 191 | + c = self._query_adresses_quebec(address, city) | |
| 192 | + if c is not None and _in_bbox(*c, bbox): | |
| 193 | + coords = c | |
| 194 | + provider = "adresses_quebec" | |
| 150 | 195 | ok = coords is not None |
| 151 | 196 | self.con.execute( |
| 152 | 197 | "INSERT INTO geocode_cache (address, lat, lng, provider, failed, ts)" |
@@ -155,7 +200,7 @@ class Geocoder: | ||
| 155 | 200 | " lng=excluded.lng, provider=excluded.provider," |
| 156 | 201 | " failed=excluded.failed, ts=excluded.ts", |
| 157 | 202 | (key, coords[0] if ok else None, coords[1] if ok else None, |
| 158 | − "nominatim", 0 if ok else 1, time.time())) | |
| 203 | + provider, 0 if ok else 1, time.time())) | |
| 159 | 204 | self.con.commit() |
| 160 | 205 | return coords if ok else None |
| 161 | 206 | |
| 162 | 207 | |