SPB Git

spb/lou-ka Public

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

HTML 99.7%

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>
simon-pierre boucher committed 2 days ago (Aug 8, 2026) parent 0739743

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