| 8 |
8 |
# - validation : les coordonnées doivent tomber dans la région attendue |
| 9 |
9 |
# (bounding box par ville), sinon flag geocode_failed — jamais de |
| 10 |
10 |
# coordonnées bidon |
|
11 |
+# - rigueur : chaque résultat est validé contre le CENTROÏDE de la ville |
|
12 |
+# 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é. Corrige |
|
14 |
+# la classe de bug « rue homonyme dans une autre ville » (ex. rue des |
|
15 |
+# Perdrix de Jonquière géocodée à Saint-Augustin). |
| 11 |
16 |
# ----------------------------------------------------------------------------- |
| 12 |
17 |
from __future__ import annotations |
| 13 |
18 |
|
| 14 |
19 |
import json |
|
20 |
+import math |
| 15 |
21 |
import sqlite3 |
|
22 |
+import statistics |
| 16 |
23 |
|
| 17 |
24 |
import re |
| 18 |
25 |
import time |
| 44 |
51 |
_VILLES_QUEBEC = {"quebec", "levis", "saint-augustin-de-desmaures", |
| 45 |
52 |
"l'ancienne-lorette", "ancienne-lorette"} |
| 46 |
53 |
|
|
54 |
+# Validation « rigoureuse » : distance max entre un résultat de géocodage et une |
|
55 |
+# référence de la ville annoncée. 40 km tolère les grandes municipalités |
|
56 |
+# fusionnées (Saguenay, Gatineau, La Tuque…) tout en rejetant les rues |
|
57 |
+# homonymes d'une autre région (Saint-Augustin ↔ Jonquière = ~190 km). |
|
58 |
+# ⚠ Le Québec compte de nombreuses municipalités HOMONYMES (deux L'Ange-Gardien, |
|
59 |
+# deux Saint-Donat, deux Sainte-Félicité…) : la référence n'est donc jamais un |
|
60 |
+# centroïde unique mais un ENSEMBLE de grappes de fiches (chaque homonyme garde |
|
61 |
+# la sienne) + le point Nominatim de la ville. |
|
62 |
+VILLE_RADIUS_KM = 40.0 |
|
63 |
+CLUSTER_MIN = 3 # une grappe de fiches devient une référence de confiance dès n >= 3 |
|
64 |
+ |
| 47 |
65 |
|
| 48 |
66 |
def _bbox_for(city: str) -> tuple[float, float, float, float]: |
| 49 |
67 |
key = strip_accents((city or "").strip().lower()) |
| 54 |
72 |
return bbox[0] <= lat <= bbox[1] and bbox[2] <= lng <= bbox[3] |
| 55 |
73 |
|
| 56 |
74 |
|
|
75 |
+def _dist_km(lat1: float, lng1: float, lat2: float, lng2: float) -> float: |
|
76 |
+ """Distance haversine en kilomètres.""" |
|
77 |
+ p1, p2 = math.radians(lat1), math.radians(lat2) |
|
78 |
+ a = (math.sin((p2 - p1) / 2) ** 2 |
|
79 |
+ + math.cos(p1) * math.cos(p2) * math.sin(math.radians(lng2 - lng1) / 2) ** 2) |
|
80 |
+ return 2 * 6371.0 * math.asin(math.sqrt(a)) |
|
81 |
+ |
|
82 |
+ |
| 57 |
83 |
def norm_key(address: str) -> str: |
| 58 |
84 |
"""Clé de cache : adresse normalisée (casse, accents, espaces, n° d'app.).""" |
| 59 |
85 |
s = strip_accents((address or "").lower()) |
| 68 |
94 |
self.session = requests.Session() |
| 69 |
95 |
self.session.headers["User-Agent"] = USER_AGENT |
| 70 |
96 |
self._last = 0.0 |
|
97 |
+ self._villes: dict[str, dict | None] = {} # norm(ville) -> {lat,lng,muni} |
|
98 |
+ self._med: dict[str, list[tuple]] | None = None # norm(ville) -> grappes (lat,lng,n) |
|
99 |
+ self.ville_api_calls = 0 # requêtes Nominatim « ville » |
| 71 |
100 |
|
| 72 |
101 |
@staticmethod |
| 73 |
102 |
def _clean(address: str) -> str: |
| 149 |
178 |
except (KeyError, ValueError): |
| 150 |
179 |
return None |
| 151 |
180 |
|
| 152 |
|
− def _attempts(self, address: str, city: str) -> list[dict]: |
|
181 |
+ def _attempts(self, address: str, city: str, muni: str | None = None) -> list[dict]: |
| 153 |
182 |
"""Stratégies de requête, de la plus précise à la moins précise. |
| 154 |
183 |
|
| 155 |
184 |
1) structurée civique+rue (évite l'ambiguïté « Québec » ville/province) |
| 156 |
|
− 2) recherche libre complète |
| 157 |
|
− 3) structurée rue seule -> centroïde de rue (repli acceptable pour la |
|
185 |
+ 2) idem avec la municipalité OFFICIELLE (« Saguenay ») quand la ville |
|
186 |
+ annoncée est un secteur (« Jonquière (Lac-Kénogami) ») inconnu des API |
|
187 |
+ 3) recherche libre complète |
|
188 |
+ 4) structurée rue seule -> centroïde de rue (repli acceptable pour la |
| 158 |
189 |
carte quand le numéro civique est absent d'OpenStreetMap) |
| 159 |
190 |
""" |
| 160 |
191 |
premier = self._clean(address).split(",")[0].strip() |
| 162 |
193 |
if not ville: |
| 163 |
194 |
k = strip_accents(address.lower()) |
| 164 |
195 |
ville = "Lévis" if "levis" in k else "Québec" |
| 165 |
|
− commun = {"city": ville, "state": "Québec", "country": "Canada"} |
|
196 |
+ villes = [ville] |
|
197 |
+ # premier mot de la ville (« Jonquiere Lac Kenogami » -> « Jonquiere ») |
|
198 |
+ if " " in ville: |
|
199 |
+ villes.append(ville.split(" ")[0]) |
|
200 |
+ if muni and norm_key(muni) not in {norm_key(v) for v in villes}: |
|
201 |
+ villes.append(muni) |
| 166 |
202 |
|
| 167 |
203 |
tries: list[dict] = [] |
| 168 |
204 |
m = re.match(r"^(\d+)[,\s]+(.{4,})$", premier) |
| 169 |
|
− if m: |
| 170 |
|
− tries.append({"street": f"{m.group(1)} {m.group(2)}", **commun}) |
|
205 |
+ for v in villes: |
|
206 |
+ commun = {"city": v, "state": "Québec", "country": "Canada"} |
|
207 |
+ if m: |
|
208 |
+ tries.append({"street": f"{m.group(1)} {m.group(2)}", **commun}) |
| 171 |
209 |
tries.append({"q": self._build_query(address, city)}) |
| 172 |
|
− if m: |
| 173 |
|
− tries.append({"street": m.group(2), **commun}) |
|
210 |
+ for v in villes: |
|
211 |
+ commun = {"city": v, "state": "Québec", "country": "Canada"} |
|
212 |
+ if m: |
|
213 |
+ tries.append({"street": m.group(2), **commun}) |
| 174 |
214 |
return tries |
| 175 |
215 |
|
|
216 |
+ # ----- références de ville (validation rigoureuse) ----------------------- |
|
217 |
+ def _clusters(self) -> dict[str, list[tuple]]: |
|
218 |
+ """Grappes de coordonnées par ville, depuis les fiches déjà géocodées |
|
219 |
+ (regroupement glouton, rayon VILLE_RADIUS_KM). Chaque grappe : |
|
220 |
+ (lat médiane, lng médiane, n). Les municipalités homonymes produisent |
|
221 |
+ naturellement une grappe chacune — aucune n'écrase l'autre.""" |
|
222 |
+ if self._med is None: |
|
223 |
+ acc: dict[str, list] = {} |
|
224 |
+ for r in self.con.execute( |
|
225 |
+ """SELECT city, lat, lng FROM listings |
|
226 |
+ WHERE active=1 AND lat IS NOT NULL AND city<>''"""): |
|
227 |
+ acc.setdefault(norm_key(r["city"]), []).append((r["lat"], r["lng"])) |
|
228 |
+ out: dict[str, list[tuple]] = {} |
|
229 |
+ for k, pts in acc.items(): |
|
230 |
+ clusters: list[list] = [] # [ [lat_c, lng_c, [points]] ] |
|
231 |
+ for lat, lng in pts: |
|
232 |
+ for cl in clusters: |
|
233 |
+ if _dist_km(lat, lng, cl[0], cl[1]) <= VILLE_RADIUS_KM: |
|
234 |
+ cl[2].append((lat, lng)) |
|
235 |
+ cl[0] = statistics.median(p[0] for p in cl[2]) |
|
236 |
+ cl[1] = statistics.median(p[1] for p in cl[2]) |
|
237 |
+ break |
|
238 |
+ else: |
|
239 |
+ clusters.append([lat, lng, [(lat, lng)]]) |
|
240 |
+ out[k] = [(cl[0], cl[1], len(cl[2])) for cl in clusters] |
|
241 |
+ self._med = out |
|
242 |
+ return self._med |
|
243 |
+ |
|
244 |
+ def resolve_ville(self, city: str, allow_api: bool = True) -> dict | None: |
|
245 |
+ """Ville -> {lat, lng, muni} (muni = municipalité officielle, ex. |
|
246 |
+ « Jonquière (Lac-Kénogami) » -> Saguenay). Cache : geocode_cache, |
|
247 |
+ clé « ville:<norm> ». None si irrésoluble.""" |
|
248 |
+ k = norm_key(city or "") |
|
249 |
+ if not k: |
|
250 |
+ return None |
|
251 |
+ if k in self._villes: |
|
252 |
+ return self._villes[k] |
|
253 |
+ key = "ville:" + k |
|
254 |
+ row = self.con.execute( |
|
255 |
+ "SELECT lat, lng, muni, failed, ts FROM geocode_cache WHERE address=?", |
|
256 |
+ (key,)).fetchone() |
|
257 |
+ if row is not None: |
|
258 |
+ if not row["failed"]: |
|
259 |
+ v = {"lat": row["lat"], "lng": row["lng"], "muni": row["muni"]} |
|
260 |
+ self._villes[k] = v |
|
261 |
+ return v |
|
262 |
+ if time.time() - (row["ts"] or 0) < RETRY_FAILED_AFTER: |
|
263 |
+ self._villes[k] = None |
|
264 |
+ return None |
|
265 |
+ if not allow_api: |
|
266 |
+ return None # pas de mémoire : on réessaiera quand l'API sera permise |
|
267 |
+ self.ville_api_calls += 1 |
|
268 |
+ wait = REQUEST_DELAY - (time.time() - self._last) |
|
269 |
+ if wait > 0: |
|
270 |
+ time.sleep(wait) |
|
271 |
+ v = None |
|
272 |
+ try: |
|
273 |
+ resp = self.session.get(NOMINATIM_URL, params={ |
|
274 |
+ "q": f"{city}, Québec, Canada", "format": "jsonv2", "limit": 1, |
|
275 |
+ "countrycodes": "ca", "addressdetails": 1}, timeout=20) |
|
276 |
+ self._last = time.time() |
|
277 |
+ resp.raise_for_status() |
|
278 |
+ hits = resp.json() |
|
279 |
+ except Exception: |
|
280 |
+ self._last = time.time() |
|
281 |
+ return None # erreur réseau : ne pas cacher un échec définitif |
|
282 |
+ if hits: |
|
283 |
+ try: |
|
284 |
+ lat, lng = float(hits[0]["lat"]), float(hits[0]["lon"]) |
|
285 |
+ if _in_bbox(lat, lng, _BBOX_SUD_QC): |
|
286 |
+ a = hits[0].get("address") or {} |
|
287 |
+ muni = (a.get("city") or a.get("town") or a.get("village") |
|
288 |
+ or a.get("municipality")) |
|
289 |
+ v = {"lat": lat, "lng": lng, "muni": muni} |
|
290 |
+ except (KeyError, ValueError, TypeError): |
|
291 |
+ v = None |
|
292 |
+ self.con.execute( |
|
293 |
+ "INSERT INTO geocode_cache (address, lat, lng, muni, provider, failed, ts)" |
|
294 |
+ " VALUES (?,?,?,?,?,?,?) ON CONFLICT(address) DO UPDATE SET" |
|
295 |
+ " lat=excluded.lat, lng=excluded.lng, muni=excluded.muni," |
|
296 |
+ " provider=excluded.provider, failed=excluded.failed, ts=excluded.ts", |
|
297 |
+ (key, v["lat"] if v else None, v["lng"] if v else None, |
|
298 |
+ v["muni"] if v else None, "ville_nominatim", 0 if v else 1, time.time())) |
|
299 |
+ self.con.commit() |
|
300 |
+ self._villes[k] = v |
|
301 |
+ return v |
|
302 |
+ |
|
303 |
+ def city_refs(self, city: str, allow_api: bool = True) -> list[tuple[float, float]]: |
|
304 |
+ """Points de référence d'une ville : grappes de fiches dignes de |
|
305 |
+ confiance (n >= CLUSTER_MIN — une par municipalité homonyme) + point |
|
306 |
+ Nominatim de la ville. Liste vide = ville inconnue (-> bbox seule).""" |
|
307 |
+ k = norm_key(city or "") |
|
308 |
+ if not k: |
|
309 |
+ return [] |
|
310 |
+ refs = [(cl[0], cl[1]) for cl in self._clusters().get(k, []) |
|
311 |
+ if cl[2] >= CLUSTER_MIN] |
|
312 |
+ v = self.resolve_ville(city, allow_api=allow_api) |
|
313 |
+ if v: |
|
314 |
+ refs.append((v["lat"], v["lng"])) |
|
315 |
+ return refs |
|
316 |
+ |
|
317 |
+ def coords_ok(self, c: tuple[float, float] | None, city: str, |
|
318 |
+ refs: list[tuple[float, float]] | None = None) -> bool: |
|
319 |
+ """Valide un candidat : dans la bbox ET à <= VILLE_RADIUS_KM d'au moins |
|
320 |
+ une référence de la ville annoncée (quand elle en a).""" |
|
321 |
+ if c is None or not _in_bbox(*c, _bbox_for(city)): |
|
322 |
+ return False |
|
323 |
+ if refs is None: |
|
324 |
+ refs = self.city_refs(city) |
|
325 |
+ return not refs or any(_dist_km(c[0], c[1], r[0], r[1]) <= VILLE_RADIUS_KM |
|
326 |
+ for r in refs) |
|
327 |
+ |
| 176 |
328 |
def resolve(self, address: str, city: str) -> tuple[float, float] | None: |
| 177 |
|
− """Adresse -> (lat, lng), via cache puis Nominatim. None si introuvable.""" |
|
329 |
+ """Adresse -> (lat, lng), via cache puis AQ/Nominatim. None si introuvable. |
|
330 |
+ |
|
331 |
+ Rigueur : chaque candidat doit être cohérent avec la ville annoncée |
|
332 |
+ (centroïde <= VILLE_RADIUS_KM). Si la ville annoncée est un secteur |
|
333 |
+ inconnu d'Adresses Québec, on réessaie avec la municipalité officielle. |
|
334 |
+ """ |
| 178 |
335 |
key = norm_key(address) |
| 179 |
336 |
if not key or len(key) < 6: |
| 180 |
337 |
return None |
| 187 |
344 |
if time.time() - (row["ts"] or 0) < RETRY_FAILED_AFTER: |
| 188 |
345 |
return None # échec récent : ne pas marteler l'API |
| 189 |
346 |
|
| 190 |
|
− bbox = _bbox_for(city) |
|
347 |
+ refs = self.city_refs(city) |
| 191 |
348 |
coords = None |
| 192 |
349 |
# Adresses Québec (MERN) d'abord : rapide, autoritatif au Québec, pas de |
| 193 |
350 |
# limite stricte. Nominatim (1 req/s) seulement en repli. |
| 194 |
351 |
provider = "adresses_quebec" |
| 195 |
352 |
c = self._query_adresses_quebec(address, city) |
| 196 |
|
− if c is not None and _in_bbox(*c, bbox): |
|
353 |
+ if self.coords_ok(c, city, refs): |
| 197 |
354 |
coords = c |
|
355 |
+ muni = None |
|
356 |
+ if coords is None: |
|
357 |
+ # ville annoncée = secteur (« Jonquière (Lac-Kénogami) ») ? AQ ne |
|
358 |
+ # connaît que les municipalités officielles -> retenter avec elle. |
|
359 |
+ v = self.resolve_ville(city) |
|
360 |
+ muni = (v or {}).get("muni") |
|
361 |
+ if muni and norm_key(muni) != norm_key(city or ""): |
|
362 |
+ c = self._query_adresses_quebec(address, muni) |
|
363 |
+ if self.coords_ok(c, city, refs): |
|
364 |
+ coords, provider = c, "adresses_quebec_muni" |
| 198 |
365 |
if coords is None: |
| 199 |
366 |
provider = "nominatim" |
| 200 |
|
− for params in self._attempts(address, city): |
|
367 |
+ for params in self._attempts(address, city, muni): |
| 201 |
368 |
c = self._query_nominatim(params) |
| 202 |
|
− if c is not None and _in_bbox(*c, bbox): |
|
369 |
+ if self.coords_ok(c, city, refs): |
| 203 |
370 |
coords = c |
| 204 |
371 |
break |
| 205 |
372 |
ok = coords is not None |
| 278 |
445 |
AQ_BATCH_URL = ("https://servicescarto.mern.gouv.qc.ca/pes/rest/services/Territoire/" |
| 279 |
446 |
"Adresse_Geocodage/GeocodeServer/geocodeAddresses") |
| 280 |
447 |
BATCH_SIZE = 200 |
|
448 |
+RESCUE_MAX = 150 # rattrapages resolve() max par passe (Nominatim = 1 req/s) |
|
449 |
+ |
|
450 |
+ |
|
451 |
+def strip_bad_source_coords(con, listings) -> int: |
|
452 |
+ """Garde d'ingestion : annule les lat/lng fournis par un connecteur quand |
|
453 |
+ ils contredisent la ville annoncée (> VILLE_RADIUS_KM du centroïde connu). |
|
454 |
+ Cache seulement (médianes + villes déjà résolues) : aucun appel réseau, |
|
455 |
+ la synchronisation reste rapide. Retourne le nombre de paires annulées.""" |
|
456 |
+ geo = Geocoder(con) |
|
457 |
+ n = 0 |
|
458 |
+ for lst in listings: |
|
459 |
+ if lst.lat is None or lst.lng is None or not (lst.city or "").strip(): |
|
460 |
+ continue |
|
461 |
+ refs = geo.city_refs(lst.city, allow_api=False) |
|
462 |
+ if refs and not geo.coords_ok((lst.lat, lst.lng), lst.city, refs): |
|
463 |
+ lst.lat = lst.lng = None |
|
464 |
+ n += 1 |
|
465 |
+ return n |
| 281 |
466 |
|
| 282 |
467 |
|
| 283 |
468 |
def run_batch(limit: int | None = None) -> dict: |
| 318 |
503 |
session = requests.Session() |
| 319 |
504 |
session.headers["User-Agent"] = USER_AGENT |
| 320 |
505 |
done = failed = 0 |
|
506 |
+ rescue: list[tuple[str, dict]] = [] # échecs du lot -> resolve() individuel |
| 321 |
507 |
for i in range(0, len(pending), BATCH_SIZE): |
| 322 |
508 |
chunk = pending[i:i + BATCH_SIZE] |
| 323 |
509 |
records = {"records": [ |
| 344 |
530 |
lc = loc.get("location") or {} |
| 345 |
531 |
try: |
| 346 |
532 |
cand = (float(lc["y"]), float(lc["x"])) |
| 347 |
|
− if _in_bbox(*cand, _bbox_for(u["city"])): |
|
533 |
+ # bbox + cohérence avec le centroïde de la ville annoncée |
|
534 |
+ if geo.coords_ok(cand, u["city"]): |
| 348 |
535 |
coords = cand |
| 349 |
536 |
except (KeyError, ValueError, TypeError): |
| 350 |
537 |
coords = None |
| 360 |
547 |
" failed=0, ts=excluded.ts", |
| 361 |
548 |
(k, coords[0], coords[1], "adresses_quebec_batch", time.time())) |
| 362 |
549 |
done += len(u["members"]) |
|
550 |
+ con.commit() |
| 363 |
551 |
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() |
|
552 |
+ # pas de verdict tout de suite : resolve() réessaiera avec la |
|
553 |
+ # municipalité officielle + Nominatim (borné par RESCUE_MAX) |
|
554 |
+ rescue.append((k, u)) |
| 373 |
555 |
except sqlite3.OperationalError: |
| 374 |
556 |
try: con.rollback() |
| 375 |
557 |
except sqlite3.Error: pass |
| 376 |
558 |
time.sleep(1.0) |
| 377 |
559 |
print(f"[immo-ka] geocode-batch {i+len(chunk)}/{len(pending)} " |
| 378 |
|
− f"(résolues {done}, échecs {failed})") |
|
560 |
+ f"(résolues {done}, à rattraper {len(rescue)})") |
|
561 |
+ |
|
562 |
+ # Rattrapage 1-par-1 des échecs du lot : variantes de municipalité (AQ) puis |
|
563 |
+ # Nominatim, validées par centroïde. resolve() écrit lui-même le verdict au |
|
564 |
+ # cache (succès ou échec 30 j) ; au-delà de RESCUE_MAX, on laisse les fiches |
|
565 |
+ # intactes (ni cache ni flag) -> reprises aux prochains passages. |
|
566 |
+ rescued = 0 |
|
567 |
+ for k, u in rescue[:RESCUE_MAX]: |
|
568 |
+ try: |
|
569 |
+ coords = geo.resolve(u["address"], u["city"]) |
|
570 |
+ if coords: |
|
571 |
+ for uid in u["members"]: |
|
572 |
+ con.execute("UPDATE listings SET lat=?, lng=? WHERE uid=?", |
|
573 |
+ (coords[0], coords[1], uid)) |
|
574 |
+ done += len(u["members"]) |
|
575 |
+ rescued += len(u["members"]) |
|
576 |
+ else: |
|
577 |
+ for uid in u["members"]: |
|
578 |
+ con.execute("UPDATE listings SET geocode_failed=1 WHERE uid=?", (uid,)) |
|
579 |
+ failed += len(u["members"]) |
|
580 |
+ con.commit() |
|
581 |
+ except sqlite3.OperationalError: |
|
582 |
+ try: con.rollback() |
|
583 |
+ except sqlite3.Error: pass |
|
584 |
+ time.sleep(1.0) |
| 379 |
585 |
con.close() |
| 380 |
|
− stats = {"geocoded": done, "failed": failed, "batches": (len(pending)+BATCH_SIZE-1)//BATCH_SIZE} |
|
586 |
+ stats = {"geocoded": done, "rescued": rescued, "failed": failed, |
|
587 |
+ "deferred": max(0, len(rescue) - RESCUE_MAX), |
|
588 |
+ "batches": (len(pending)+BATCH_SIZE-1)//BATCH_SIZE} |
| 381 |
589 |
print(f"[immo-ka] geocode-batch {stats}") |
| 382 |
590 |
return stats |
|
591 |
+ |
|
592 |
+ |
|
593 |
+def run_audit(nominatim_budget: int | None = None, apply: bool = True) -> dict: |
|
594 |
+ """Audit de cohérence géographique des fiches DÉJÀ géocodées. |
|
595 |
+ |
|
596 |
+ Détecte les coordonnées à plus de VILLE_RADIUS_KM de TOUTE référence de |
|
597 |
+ leur ville — grappes de fiches (une par municipalité homonyme) + point |
|
598 |
+ Nominatim — puis les remet en file de géocodage : lat/lng annulés, |
|
599 |
+ geocode_failed remis à 0, entrée de cache purgée. Une fiche n'est JAMAIS |
|
600 |
+ flaguée sans référence indépendante confirmant l'incohérence (les |
|
601 |
+ homonymes et villes irrésolues sont épargnés). `nominatim_budget` borne |
|
602 |
+ les résolutions de villes inconnues (1 req/s) ; None = illimité. |
|
603 |
+ `apply=False` = rapport seul. |
|
604 |
+ """ |
|
605 |
+ con = db.connect() |
|
606 |
+ geo = Geocoder(con) |
|
607 |
+ rows = con.execute( |
|
608 |
+ """SELECT uid, address, city, lat, lng FROM listings |
|
609 |
+ WHERE active=1 AND lat IS NOT NULL AND city<>''""").fetchall() |
|
610 |
+ flagged, inconnues, reportees = [], 0, 0 |
|
611 |
+ for r in rows: |
|
612 |
+ k = norm_key(r["city"]) |
|
613 |
+ clusters = [(cl[0], cl[1]) for cl in geo._clusters().get(k, []) |
|
614 |
+ if cl[2] >= CLUSTER_MIN] |
|
615 |
+ dists = [_dist_km(r["lat"], r["lng"], c[0], c[1]) for c in clusters] |
|
616 |
+ if dists and min(dists) <= VILLE_RADIUS_KM: |
|
617 |
+ continue # cohérente avec une grappe de sa ville |
|
618 |
+ budget_ok = nominatim_budget is None or geo.ville_api_calls < nominatim_budget |
|
619 |
+ v = geo.resolve_ville(r["city"], allow_api=budget_ok) |
|
620 |
+ if v is not None: |
|
621 |
+ d = _dist_km(r["lat"], r["lng"], v["lat"], v["lng"]) |
|
622 |
+ if d <= VILLE_RADIUS_KM: |
|
623 |
+ continue # cohérente avec le point Nominatim de la ville |
|
624 |
+ flagged.append((r, min(dists + [d]))) |
|
625 |
+ elif not clusters: |
|
626 |
+ inconnues += 1 # aucune référence : on ne flague pas |
|
627 |
+ elif not budget_ok: |
|
628 |
+ reportees += 1 # ville pas encore résolue : prochaine passe |
|
629 |
+ else: |
|
630 |
+ flagged.append((r, min(dists))) # ville irrésoluble, grappes loin |
|
631 |
+ if apply: |
|
632 |
+ for r, d in flagged: |
|
633 |
+ con.execute("DELETE FROM geocode_cache WHERE address=?", |
|
634 |
+ (norm_key(r["address"]),)) |
|
635 |
+ con.execute("""UPDATE listings SET lat=NULL, lng=NULL, |
|
636 |
+ geocode_failed=0 WHERE uid=?""", (r["uid"],)) |
|
637 |
+ con.commit() |
|
638 |
+ for r, d in flagged[:20]: |
|
639 |
+ print(f"[immo-ka] audit: {r['uid']} « {r['address']}, {r['city']} » " |
|
640 |
+ f"à {d:.0f} km de sa ville") |
|
641 |
+ con.close() |
|
642 |
+ stats = {"verifiees": len(rows), "incoherentes": len(flagged), |
|
643 |
+ "villes_inconnues": inconnues, "reportees": reportees, |
|
644 |
+ "corrigees": apply} |
|
645 |
+ print(f"[immo-ka] geocode-audit {stats}") |
|
646 |
+ return stats |