SPB Git forge

spb/immo-ka

Public

Immo-Ka — agrégateur des propriétés à vendre au Québec (73 connecteurs, ~40 000 annonces, React+FastAPI)

112commits 1branches 0releases
125.4 MBsize
maindefault branch
13 days agolast push
Python 47.5% HTML 27.9% TypeScript 15.5% CSS 7.2% JavaScript 2%

Géocodage rigoureux : validation par références de ville (grappes + Nominatim)

Corrige la classe de bug « rue homonyme d'une autre ville » (4671 rue des
Perdrix, Jonquière/Lac-Kénogami affichée à Saint-Augustin, ~180 km) :

- chaque résultat de géocodage doit être à <= 40 km d'une référence de la
  ville annoncée : grappes de fiches déjà géocodées (une par municipalité
  HOMONYME — deux L'Ange-Gardien, deux Saint-Donat…) + point Nominatim de la
  ville (cache geocode_cache, clé « ville:… », colonne muni ajoutée)
- resolve() : si la ville annoncée est un secteur inconnu d'Adresses Québec
  (« Jonquière (Lac-Kénogami) »), retry AQ avec la municipalité officielle
  (« Saguenay ») dérivée de Nominatim, puis variantes Nominatim structurées
- run_batch : validation par références + rattrapage resolve() individuel des
  échecs du lot (RESCUE_MAX/passe, file auto-drainante)
- run_audit (CLI geocode-audit [budget] [dry]) : détecte les fiches actives
  aux coordonnées incohérentes avec leur ville et les remet en file de
  géocodage ; intégré au cycle watch (budget Nominatim 50/passe)
- garde d'ingestion : lat/lng fournis par un connecteur (ex. JSON-LD
  DuProprio erroné) rejetés s'ils contredisent la ville (cache seulement)

Audit initial : 110 574 fiches vérifiées, 3 incohérentes corrigées et
re-géocodées correctement (les municipalités homonymes ne sont pas flaguées).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Simon-Pierre Boucher committed 1 mo ago (Aug 25, 2026) parent 2b1f7ce

4 changed files +314 −26

modified immoka/db.py +5 −1
@@ -108,7 +108,8 @@ CREATE TABLE IF NOT EXISTS geocode_cache (
108 108 lng REAL,
109 109 provider TEXT,
110 110 failed INTEGER DEFAULT 0,
111 − ts REAL
111 + ts REAL,
112 + muni TEXT -- municipalité officielle (clés « ville:… »)
112 113 );
113 114
114 115 CREATE TABLE IF NOT EXISTS poi_cache (
@@ -138,6 +139,9 @@ def _init_schema(con: sqlite3.Connection) -> None:
138 139 con.execute("ALTER TABLE listings ADD COLUMN dauid TEXT")
139 140 if "vraiprix" not in cols: # estimation Vrai-Prix (JSON) + lien analyse
140 141 con.execute("ALTER TABLE listings ADD COLUMN vraiprix TEXT")
142 + gcols = {r["name"] for r in con.execute("PRAGMA table_info(geocode_cache)")}
143 + if "muni" not in gcols: # municipalité officielle (entrées « ville:… »)
144 + con.execute("ALTER TABLE geocode_cache ADD COLUMN muni TEXT")
141 145 con.execute("CREATE INDEX IF NOT EXISTS idx_listings_duphidden ON listings(dup_hidden)")
142 146 con.execute("CREATE INDEX IF NOT EXISTS idx_listings_geo ON listings(lat, lng)")
143 147 con.commit()
modified immoka/geocode.py +289 −25
@@ -8,11 +8,18 @@
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,6 +51,17 @@ _BBOX_SUD_QC = (44.50, 63.00, -80.00, -56.00)
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,6 +72,14 @@ def _in_bbox(lat: float, lng: float, bbox: tuple) -> bool:
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,6 +94,9 @@ class Geocoder:
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,12 +178,14 @@ class Geocoder:
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,19 +193,145 @@ class Geocoder:
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,19 +344,29 @@ class Geocoder:
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,6 +445,24 @@ def run(limit: int | None = None) -> dict:
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,6 +503,7 @@ 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,7 +530,8 @@ def run_batch(limit: int | None = None) -> dict:
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,23 +547,100 @@ def run_batch(limit: int | None = None) -> dict:
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
modified immoka/ingest.py +14 −0
@@ -34,6 +34,16 @@ def run(sources: list[str] | None = None) -> list[dict]:
34 34 finalized.append(lst.finalize())
35 35 except Exception: # une annonce malformée ne bloque pas la source
36 36 dropped += 1
37 + try:
38 + # garde géographique : coordonnées fournies par la source mais
39 + # incompatibles avec la ville annoncée -> annulées (le géocodeur
40 + # rigoureux prendra le relais). Cache seulement, aucun réseau.
41 + from . import geocode
42 + bad = geocode.strip_bad_source_coords(con, finalized)
43 + if bad:
44 + print(f"[immo-ka] {bad} coordonnée(s) source incohérente(s) rejetée(s)")
45 + except Exception:
46 + traceback.print_exc()
37 47 stats = db.sync_source(con, sid, finalized)
38 48 stats["seconds"] = round(time.time() - t0, 1)
39 49 if dropped:
@@ -85,6 +95,10 @@ def watch(interval_seconds: int = 3600) -> None:
85 95 run()
86 96 try: # nouvelles adresses → coordonnées (cache : quasi gratuit ensuite)
87 97 from . import geocode
98 + # audit de cohérence d'abord : les fiches mal localisées (rue
99 + # homonyme, geo source erroné) sont remises en file, puis le lot
100 + # les re-géocode rigoureusement (budget Nominatim borné par passe)
101 + geocode.run_audit(nominatim_budget=50)
88 102 geocode.run_batch()
89 103 except Exception as exc:
90 104 print(f"[immo-ka] geocode: erreur non bloquante: {exc}", file=sys.stderr)
modified run.py +6 −0
@@ -10,6 +10,7 @@
10 10 python run.py serve [port] # démarre l'API + le frontend (défaut 8090)
11 11 python run.py list # liste les connecteurs enregistrés
12 12 python run.py geocode [n] # géocode les annonces sans coordonnées (max n requêtes)
13 + python run.py geocode-audit [n] [dry] # audit coordonnées <-> ville (n = budget Nominatim)
13 14 python run.py poi [n] # commodités de proximité par immeuble (max n requêtes)
14 15 python run.py quartier [n] # rattache chaque annonce à son aire de diffusion (DAUID)
15 16 python run.py mortgage-sync [slug…] # collecte les taux hypothécaires (toutes banques ou celles listées)
@@ -52,6 +53,11 @@ def main() -> None:
52 53 elif cmd == "geocode1": # ancien mode 1-par-1 (+ repli Nominatim)
53 54 from immoka import geocode
54 55 geocode.run(int(sys.argv[2]) if len(sys.argv) > 2 else None)
56 + elif cmd == "geocode-audit": # cohérence coordonnées <-> ville annoncée
57 + from immoka import geocode
58 + budget = next((int(a) for a in sys.argv[2:] if a.isdigit()), None)
59 + geocode.run_audit(nominatim_budget=budget,
60 + apply="dry" not in sys.argv[2:])
55 61 elif cmd == "poi":
56 62 from immoka import poi
57 63 poi.run(int(sys.argv[2]) if len(sys.argv) > 2 else None)
58 64