feat: matching Vrai-Prix spatial+text durci (civique obligatoire), géocodage batch AQ, filtre prix sur demande, pagination dans l'URL
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
6 changed files +455 −20
modified
frontend/src/pages/Home.tsx
+25 −5
@@ -3,7 +3,7 @@ | ||
| 3 | 3 | // Auteur : Simon-Pierre Boucher — contact@spboucher.ai |
| 4 | 4 | // pages/Home.tsx : accueil — héro, statistiques, filtres avancés, grille + carte |
| 5 | 5 | // ----------------------------------------------------------------------------- |
| 6 | −import { Suspense, lazy, useEffect, useMemo, useState } from "react"; | |
| 6 | +import { Suspense, lazy, useEffect, useMemo, useRef, useState } from "react"; | |
| 7 | 7 | import { useSearchParams } from "react-router-dom"; |
| 8 | 8 | import { |
| 9 | 9 | Facets, Listing, ListingFilters, Stats, |
@@ -36,13 +36,12 @@ function pageNumbers(p: number, n: number): (number | "…")[] { | ||
| 36 | 36 | export default function Home() { |
| 37 | 37 | const [listings, setListings] = useState<Listing[] | null>(null); |
| 38 | 38 | const [total, setTotal] = useState(0); |
| 39 | − const [page, setPage] = useState(1); // pagination 12/page | |
| 39 | + const [params, setParams] = useSearchParams(); | |
| 40 | + const [page, setPage] = useState(Number(params.get("page")) || 1); // 12/page, dans l'URL | |
| 40 | 41 | const [facets, setFacets] = useState<Facets | null>(null); |
| 41 | 42 | const [sectors, setSectors] = useState<string[]>([]); |
| 42 | 43 | const [stats, setStats] = useState<Stats | null>(null); |
| 43 | 44 | const [error, setError] = useState<string | null>(null); |
| 44 | − | |
| 45 | − const [params] = useSearchParams(); | |
| 46 | 45 | const [q, setQ] = useState(params.get("q") ?? ""); |
| 47 | 46 | const [city, setCity] = useState(params.get("city") ?? ""); |
| 48 | 47 | const [sector, setSector] = useState(params.get("sector") ?? ""); |
@@ -80,7 +79,28 @@ export default function Home() { | ||
| 80 | 79 | }, [city]); |
| 81 | 80 | |
| 82 | 81 | // revenir à la page 1 quand les filtres changent |
| 83 | − useEffect(() => { setPage(1); }, [filters]); | |
| 82 | + const firstRender = useRef(true); | |
| 83 | + useEffect(() => { | |
| 84 | + if (firstRender.current) { firstRender.current = false; return; } | |
| 85 | + setPage(1); | |
| 86 | + }, [filters]); | |
| 87 | + | |
| 88 | + // synchroniser filtres + page + tri + vue dans l'URL → le retour arrière | |
| 89 | + // depuis une fiche revient à la MÊME page/filtres. | |
| 90 | + useEffect(() => { | |
| 91 | + const p = new URLSearchParams(); | |
| 92 | + const set = (k: string, v: string) => { if (v) p.set(k, v); }; | |
| 93 | + set("q", q); set("city", city); set("sector", sector); | |
| 94 | + set("property_type", ptype); set("source", source); | |
| 95 | + set("price_min", priceMin); set("price_max", priceMax); | |
| 96 | + set("bedrooms_min", bedsMin); set("bathrooms_min", bathsMin); | |
| 97 | + set("area_min", areaMin); | |
| 98 | + if (sort && sort !== "recent") p.set("sort", sort); | |
| 99 | + if (view === "carte") p.set("view", "carte"); | |
| 100 | + if (page > 1) p.set("page", String(page)); | |
| 101 | + setParams(p, { replace: true }); | |
| 102 | + }, [filters, page, view, q, city, sector, ptype, source, priceMin, priceMax, | |
| 103 | + bedsMin, bathsMin, areaMin, sort, setParams]); | |
| 84 | 104 | |
| 85 | 105 | // charger la page courante (12 annonces) — remplace la grille |
| 86 | 106 | useEffect(() => { |
modified
immoka/db.py
+3 −1
@@ -179,7 +179,9 @@ def connect() -> sqlite3.Connection: | ||
| 179 | 179 | con.row_factory = sqlite3.Row |
| 180 | 180 | # WAL + busy_timeout : accès concurrents (watcher + web) sans « db is locked ». |
| 181 | 181 | con.execute("PRAGMA journal_mode=WAL") |
| 182 | − con.execute("PRAGMA busy_timeout=60000") | |
| 182 | + # 120 s : couvre les longues transactions (refresh_dedup ~70 s) sans que les | |
| 183 | + # autres écrivains (géocodage, vraiprix) ne lèvent « database is locked ». | |
| 184 | + con.execute("PRAGMA busy_timeout=120000") | |
| 183 | 185 | con.execute("PRAGMA synchronous=NORMAL") |
| 184 | 186 | if not _SCHEMA_READY: |
| 185 | 187 | _init_schema(con) |
modified
immoka/geocode.py
+133 −13
@@ -11,6 +11,9 @@ | ||
| 11 | 11 | # ----------------------------------------------------------------------------- |
| 12 | 12 | from __future__ import annotations |
| 13 | 13 | |
| 14 | +import json | |
| 15 | +import sqlite3 | |
| 16 | + | |
| 14 | 17 | import re |
| 15 | 18 | import time |
| 16 | 19 | |
@@ -241,22 +244,139 @@ def run(limit: int | None = None) -> dict: | ||
| 241 | 244 | if limit is not None and requests_made >= limit: |
| 242 | 245 | continue |
| 243 | 246 | requests_made += 1 |
| 244 | − coords = geo.resolve(members[0]["address"], members[0]["city"]) | |
| 245 | − if coords: | |
| 246 | − for r in members: | |
| 247 | − con.execute("UPDATE listings SET lat=?, lng=? WHERE uid=?", | |
| 248 | − (coords[0], coords[1], r["uid"])) | |
| 249 | − done += len(members) | |
| 250 | − else: | |
| 251 | − # introuvable/hors zone : flag pour révision, jamais de coordonnées bidon | |
| 252 | − for r in members: | |
| 253 | − con.execute("UPDATE listings SET geocode_failed=1 WHERE uid=?", | |
| 254 | − (r["uid"],)) | |
| 255 | − failed += len(members) | |
| 256 | − con.commit() | |
| 247 | + try: | |
| 248 | + coords = geo.resolve(members[0]["address"], members[0]["city"]) | |
| 249 | + if coords: | |
| 250 | + for r in members: | |
| 251 | + con.execute("UPDATE listings SET lat=?, lng=? WHERE uid=?", | |
| 252 | + (coords[0], coords[1], r["uid"])) | |
| 253 | + done += len(members) | |
| 254 | + else: | |
| 255 | + # introuvable/hors zone : flag ; jamais de coordonnées bidon | |
| 256 | + for r in members: | |
| 257 | + con.execute("UPDATE listings SET geocode_failed=1 WHERE uid=?", | |
| 258 | + (r["uid"],)) | |
| 259 | + failed += len(members) | |
| 260 | + con.commit() | |
| 261 | + except sqlite3.OperationalError: | |
| 262 | + # verrou transitoire (watcher/refresh_dedup) : on saute cette adresse, | |
| 263 | + # elle sera reprise au prochain passage — jamais crasher tout le run. | |
| 264 | + try: | |
| 265 | + con.rollback() | |
| 266 | + except sqlite3.Error: | |
| 267 | + pass | |
| 268 | + time.sleep(1.0) | |
| 269 | + continue | |
| 257 | 270 | |
| 258 | 271 | con.close() |
| 259 | 272 | stats = {"geocoded": done, "failed": failed, |
| 260 | 273 | "unique_addresses": len(groupes), "api_requests": requests_made} |
| 261 | 274 | print(f"[immo-ka] geocode {stats}") |
| 262 | 275 | return stats |
| 276 | + | |
| 277 | + | |
| 278 | +AQ_BATCH_URL = ("https://servicescarto.mern.gouv.qc.ca/pes/rest/services/Territoire/" | |
| 279 | + "Adresse_Geocodage/GeocodeServer/geocodeAddresses") | |
| 280 | +BATCH_SIZE = 200 | |
| 281 | + | |
| 282 | + | |
| 283 | +def run_batch(limit: int | None = None) -> dict: | |
| 284 | + """Géocodage EN LOT via Adresses Québec (`geocodeAddresses`, jusqu'à 1000 | |
| 285 | + adresses/requête) — ~35 requêtes pour tout le parc au lieu de dizaines de | |
| 286 | + milliers. Beaucoup plus rapide que le mode 1-par-1 (et que Nominatim).""" | |
| 287 | + con = db.connect() | |
| 288 | + geo = Geocoder(con) # pour réutiliser _clean / bbox | |
| 289 | + rows = con.execute( | |
| 290 | + """SELECT uid, address, city FROM listings | |
| 291 | + WHERE active=1 AND lat IS NULL AND address<>'' AND geocode_failed=0 | |
| 292 | + AND dup_hidden=0 ORDER BY address""").fetchall() | |
| 293 | + # 1 entrée par immeuble (adresse normalisée) ; on saute les échecs en cache | |
| 294 | + uniq: dict[str, dict] = {} | |
| 295 | + for r in rows: | |
| 296 | + k = norm_key(r["address"]) | |
| 297 | + if not k or len(k) < 6: | |
| 298 | + continue | |
| 299 | + u = uniq.setdefault(k, {"address": r["address"], "city": r["city"], | |
| 300 | + "members": []}) | |
| 301 | + u["members"].append(r["uid"]) | |
| 302 | + pending = [] | |
| 303 | + for k, u in uniq.items(): | |
| 304 | + c = con.execute("SELECT lat,lng,failed FROM geocode_cache WHERE address=?", | |
| 305 | + (k,)).fetchone() | |
| 306 | + if c is not None and not c["failed"]: # déjà résolu : appliquer direct | |
| 307 | + for uid in u["members"]: | |
| 308 | + con.execute("UPDATE listings SET lat=?, lng=? WHERE uid=?", | |
| 309 | + (c["lat"], c["lng"], uid)) | |
| 310 | + continue | |
| 311 | + if c is not None and c["failed"]: | |
| 312 | + continue | |
| 313 | + pending.append((k, u)) | |
| 314 | + con.commit() | |
| 315 | + if limit is not None: | |
| 316 | + pending = pending[:limit] | |
| 317 | + | |
| 318 | + session = requests.Session() | |
| 319 | + session.headers["User-Agent"] = USER_AGENT | |
| 320 | + done = failed = 0 | |
| 321 | + for i in range(0, len(pending), BATCH_SIZE): | |
| 322 | + chunk = pending[i:i + BATCH_SIZE] | |
| 323 | + records = {"records": [ | |
| 324 | + {"attributes": {"OBJECTID": j, | |
| 325 | + "SingleLine": f"{geo._clean(u['address']).split(',')[0].strip()}, " | |
| 326 | + f"{(u['city'] or 'Québec').strip()}"}} | |
| 327 | + for j, (_k, u) in enumerate(chunk)]} | |
| 328 | + try: | |
| 329 | + # POST obligatoire : le param `addresses` (JSON de N records) est trop | |
| 330 | + # long pour une URL GET dès quelques dizaines d'adresses. | |
| 331 | + resp = session.post(AQ_BATCH_URL, data={ | |
| 332 | + "addresses": json.dumps(records, ensure_ascii=False), | |
| 333 | + "f": "json", "outSR": 4326}, timeout=90) | |
| 334 | + locs = resp.json().get("locations", []) | |
| 335 | + except Exception as e: | |
| 336 | + print(f"[immo-ka] geocode-batch lot {i//BATCH_SIZE} ERREUR: {str(e)[:80]}") | |
| 337 | + time.sleep(1.0) | |
| 338 | + continue | |
| 339 | + by_id = {l["attributes"].get("ResultID"): l for l in locs} | |
| 340 | + for j, (k, u) in enumerate(chunk): | |
| 341 | + loc = by_id.get(j) | |
| 342 | + coords = None | |
| 343 | + if loc and loc["attributes"].get("Score", 0) >= AQ_MIN_SCORE: | |
| 344 | + lc = loc.get("location") or {} | |
| 345 | + try: | |
| 346 | + cand = (float(lc["y"]), float(lc["x"])) | |
| 347 | + if _in_bbox(*cand, _bbox_for(u["city"])): | |
| 348 | + coords = cand | |
| 349 | + except (KeyError, ValueError, TypeError): | |
| 350 | + coords = None | |
| 351 | + try: | |
| 352 | + if coords: | |
| 353 | + for uid in u["members"]: | |
| 354 | + con.execute("UPDATE listings SET lat=?, lng=? WHERE uid=?", | |
| 355 | + (coords[0], coords[1], uid)) | |
| 356 | + con.execute( | |
| 357 | + "INSERT INTO geocode_cache (address,lat,lng,provider,failed,ts)" | |
| 358 | + " VALUES (?,?,?,?,0,?) ON CONFLICT(address) DO UPDATE SET" | |
| 359 | + " lat=excluded.lat, lng=excluded.lng, provider=excluded.provider," | |
| 360 | + " failed=0, ts=excluded.ts", | |
| 361 | + (k, coords[0], coords[1], "adresses_quebec_batch", time.time())) | |
| 362 | + done += len(u["members"]) | |
| 363 | + 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() | |
| 373 | + except sqlite3.OperationalError: | |
| 374 | + try: con.rollback() | |
| 375 | + except sqlite3.Error: pass | |
| 376 | + time.sleep(1.0) | |
| 377 | + print(f"[immo-ka] geocode-batch {i+len(chunk)}/{len(pending)} " | |
| 378 | + f"(résolues {done}, échecs {failed})") | |
| 379 | + con.close() | |
| 380 | + stats = {"geocoded": done, "failed": failed, "batches": (len(pending)+BATCH_SIZE-1)//BATCH_SIZE} | |
| 381 | + print(f"[immo-ka] geocode-batch {stats}") | |
| 382 | + return stats | |
added
immoka/vraiprix_local.py
+282 −0
@@ -0,0 +1,282 @@ | ||
| 1 | +# ----------------------------------------------------------------------------- | |
| 2 | +# Immo-Ka — Agrégateur de propriétés à vendre (province de Québec) | |
| 3 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +# vraiprix_local.py : appariement LOCAL contre la base Vrai-Prix (vraiprix.db, | |
| 5 | +# 3,7 M unités d'évaluation avec adresse, lat/lng, estimation, fourchette). | |
| 6 | +# Une jointure d'adresse (FTS) par annonce → remplit d'un coup : | |
| 7 | +# · lat/lng manquants (géocodage instantané, sans API externe) | |
| 8 | +# · l'estimation Vrai-Prix (valeur + P10-P90 + lien /estimation/{id}) | |
| 9 | +# Bien plus rapide que l'API vrai-prix (une requête réseau par annonce). | |
| 10 | +# ----------------------------------------------------------------------------- | |
| 11 | +from __future__ import annotations | |
| 12 | + | |
| 13 | +import json | |
| 14 | +import os | |
| 15 | +import re | |
| 16 | +import sqlite3 | |
| 17 | +import time | |
| 18 | +import unicodedata | |
| 19 | + | |
| 20 | +from . import db | |
| 21 | + | |
| 22 | +# Emplacement de la base Vrai-Prix (copiée à côté de immoka.db sur le nœud). | |
| 23 | +VP_DB = os.environ.get( | |
| 24 | + "VRAIPRIX_DB", | |
| 25 | + str((__import__("pathlib").Path(__file__).resolve().parent.parent | |
| 26 | + / "data" / "vraiprix.db"))) | |
| 27 | +SITE = "https://www.vrai-prix.com" | |
| 28 | +_BBOX = (44.5, 63.0, -80.0, -56.0) # Québec | |
| 29 | + | |
| 30 | + | |
| 31 | +def _norm(s: str) -> str: | |
| 32 | + return "".join(c for c in unicodedata.normalize("NFD", (s or "").lower()) | |
| 33 | + if unicodedata.category(c) != "Mn").strip() | |
| 34 | + | |
| 35 | + | |
| 36 | +# mots de « type de voie » ignorés dans l'appariement (variabilité rue/av/boul…) | |
| 37 | +_VOIE = {"rue", "av", "ave", "avenue", "boul", "boulevard", "bd", "blvd", | |
| 38 | + "ch", "chemin", "place", "pl", "rang", "rangs", "rg", "montee", | |
| 39 | + "montée", "mtee", "cote", "côte", "route", "rte", "terrasse", "tsse", | |
| 40 | + "ter", "impasse", "imp", "croissant", "crois", "croiss", "cours", | |
| 41 | + "allee", "allée", "prom", "promenade", "carre", "aut", "autoroute", | |
| 42 | + "de", "du", "des", "la", "le", "les", "l", "d", "et", "sur", | |
| 43 | + "est", "ouest", "nord", "sud", "st", "ste", "saint", "sainte"} | |
| 44 | + | |
| 45 | +_APP_RE = r"\b(?:app?t?|appartement|unite|suite|local|bureau|#)\s*[\w-]+" | |
| 46 | + | |
| 47 | + | |
| 48 | +def _street_words(norm_addr: str) -> set: | |
| 49 | + """Mots significatifs de la rue (sans n° civique, type de voie, n° d'app.).""" | |
| 50 | + a = re.sub(_APP_RE, " ", norm_addr.split(",")[0]) | |
| 51 | + a = re.sub(r"[^a-z0-9 ]+", " ", a) | |
| 52 | + a = re.sub(r"^\s*\d+[a-z]{0,2}(?:\s+\d+)?\s+", " ", a) # civique(s) en tête | |
| 53 | + toks = [t for t in a.split() if t] | |
| 54 | + return {t for t in toks if t not in _VOIE and len(t) > 1} | |
| 55 | + | |
| 56 | + | |
| 57 | +def _addr_parts(address: str) -> tuple[list, list]: | |
| 58 | + """→ (civiques candidats, mots de rue). Gère « 822Z » (suffixe de lettre), | |
| 59 | + « 102 50 Rue X » (app-civique : les deux nombres sont candidats) et | |
| 60 | + conserve les rues numériques (« Route 202 », « 117e Avenue »).""" | |
| 61 | + a = _norm(address).split(",")[0] | |
| 62 | + a = re.sub(_APP_RE, " ", a) | |
| 63 | + a = re.sub(r"[^a-z0-9 ]+", " ", a) | |
| 64 | + toks = [t for t in a.split() if t] | |
| 65 | + civs = [] | |
| 66 | + while toks and len(civs) < 2: | |
| 67 | + m = re.match(r"^(\d+)[a-z]{0,2}$", toks[0]) | |
| 68 | + if not m: | |
| 69 | + break | |
| 70 | + if civs and not toks[0].isdigit(): # ordinal de rue (2e, 3e…) : garder | |
| 71 | + break | |
| 72 | + civs.append(m.group(1)) | |
| 73 | + toks = toks[1:] | |
| 74 | + words = [t for t in toks if t not in _VOIE and len(t) > 1] | |
| 75 | + return civs, words | |
| 76 | + | |
| 77 | + | |
| 78 | +def _fts_query(address: str) -> tuple[str, str]: | |
| 79 | + """Requête FTS AND (n° civique + mots significatifs de la rue).""" | |
| 80 | + civs, words = _addr_parts(address) | |
| 81 | + civ = civs[-1] if civs else "" | |
| 82 | + parts = ([civ] if civ else []) + words | |
| 83 | + if not parts: | |
| 84 | + return "", civ | |
| 85 | + return " AND ".join(f'"{p}"' for p in parts), civ | |
| 86 | + | |
| 87 | + | |
| 88 | +def available() -> bool: | |
| 89 | + return os.path.exists(VP_DB) | |
| 90 | + | |
| 91 | + | |
| 92 | +def _pack(r) -> dict: | |
| 93 | + return { | |
| 94 | + "id": r["id_provinc"], "lat": r["lat"], "lng": r["lng"], | |
| 95 | + "value": r["est_hedo"] or r["est_2026"], "low": r["p10"], "high": r["p90"], | |
| 96 | + "confidence": None, "confidence_pct": None, | |
| 97 | + "url": f"{SITE}/estimation/{r['id_provinc']}", | |
| 98 | + } | |
| 99 | + | |
| 100 | + | |
| 101 | +def _meters(a1: float, o1: float, a2: float, o2: float) -> float: | |
| 102 | + """Distance approx. en mètres (équirectangulaire, ~exact à courte portée).""" | |
| 103 | + import math | |
| 104 | + dlat = (a2 - a1) * 111_000.0 | |
| 105 | + dlng = (o2 - o1) * 111_000.0 * math.cos(math.radians(a1)) | |
| 106 | + return (dlat * dlat + dlng * dlng) ** 0.5 | |
| 107 | + | |
| 108 | + | |
| 109 | +# mots génériques ignorés dans la comparaison de municipalités | |
| 110 | +_MUNI_GEN = {"saint", "sainte", "ville", "de", "du", "des", "la", "le", "les", | |
| 111 | + "sur", "au", "aux", "lac", "notre", "dame", "canton", "cantons", | |
| 112 | + "municipalite", "paroisse", "village", "mont"} | |
| 113 | + | |
| 114 | + | |
| 115 | +def _muni_norm(s: str) -> str: | |
| 116 | + s = re.sub(r"[^a-z0-9 ]+", " ", _norm(s or "")) | |
| 117 | + s = re.sub(r"\bst\b", "saint", s) | |
| 118 | + s = re.sub(r"\bste\b", "sainte", s) | |
| 119 | + return " ".join(s.split()) | |
| 120 | + | |
| 121 | + | |
| 122 | +def _muni_one(nc: str, um: str) -> bool: | |
| 123 | + if nc in um or um in nc: | |
| 124 | + return True | |
| 125 | + return bool((set(nc.split()) - _MUNI_GEN) & (set(um.split()) - _MUNI_GEN)) | |
| 126 | + | |
| 127 | + | |
| 128 | +def _muni_match(city: str, unit_muni: str, address: str = "") -> bool: | |
| 129 | + """Ville de l'annonce vs municipalité du rôle — tolère St/Ste, accents, | |
| 130 | + arrondissement (« Gatineau Aylmer ») et la ville glissée dans l'adresse | |
| 131 | + (« 1119 Ch. Dunant, Sainte-Anne-des-Lacs » avec city=« Laurentides »).""" | |
| 132 | + nc = _muni_norm(city) | |
| 133 | + if not nc: | |
| 134 | + return True | |
| 135 | + um = _muni_norm(unit_muni) | |
| 136 | + if not um: | |
| 137 | + return False | |
| 138 | + cands = [nc] + [_muni_norm(p) for p in _norm(address).split(",")[1:]] | |
| 139 | + return any(c and _muni_one(c, um) for c in cands) | |
| 140 | + | |
| 141 | + | |
| 142 | +def _concordance(address: str, city: str, r) -> tuple[bool, bool, bool]: | |
| 143 | + """(civique_ok, rue_ok, municipalité_ok) entre l'annonce et l'unité.""" | |
| 144 | + civs, words = _addr_parts(address) | |
| 145 | + ua = _norm(r["adresse"]) | |
| 146 | + civ_ok = False | |
| 147 | + mr = re.match(r"\s*(\d+)\s*-\s*(\d+)", ua) | |
| 148 | + ms = re.match(r"\s*(\d+)", ua) | |
| 149 | + for c in civs: | |
| 150 | + ci = int(c) | |
| 151 | + if mr: | |
| 152 | + lo, hi = int(mr.group(1)), int(mr.group(2)) | |
| 153 | + civ_ok = min(lo, hi) <= ci <= max(lo, hi) | |
| 154 | + elif ms: | |
| 155 | + civ_ok = ms.group(1) == c | |
| 156 | + if civ_ok: | |
| 157 | + break | |
| 158 | + iwords = set(words) | |
| 159 | + rue_ok = bool(iwords) and bool(iwords & _street_words(ua)) | |
| 160 | + muni_ok = _muni_match(city, r["municipalite"] or "", address) | |
| 161 | + return civ_ok, rue_ok, muni_ok | |
| 162 | + | |
| 163 | + | |
| 164 | +_COLS = ("id_provinc, adresse, municipalite, lat, lng, est_hedo, est_2026, p10, p90") | |
| 165 | + | |
| 166 | + | |
| 167 | +def _match(vp: sqlite3.Connection, address: str, city: str, | |
| 168 | + lat: float | None = None, lng: float | None = None) -> dict | None: | |
| 169 | + """Apparie une annonce à une unité Vrai-Prix par : | |
| 170 | + 1) PROXIMITÉ SPATIALE (si lat/lng) — l'unité la plus proche, validée par | |
| 171 | + l'adresse (text mining : civique/rue/municipalité) ; | |
| 172 | + 2) sinon recherche d'adresse FTS stricte. | |
| 173 | + Ne retourne un match que s'il est fiable (mieux vaut rien qu'un faux).""" | |
| 174 | + # 1) spatial + text-mining : candidats triés par distance | |
| 175 | + if lat is not None and lng is not None: | |
| 176 | + d = 0.0022 # ~±250 m | |
| 177 | + try: | |
| 178 | + cands = vp.execute( | |
| 179 | + f"SELECT {_COLS} FROM units" | |
| 180 | + " WHERE lat BETWEEN ? AND ? AND lng BETWEEN ? AND ?", | |
| 181 | + (lat - d, lat + d, lng - d, lng + d)).fetchall() | |
| 182 | + except sqlite3.OperationalError: | |
| 183 | + cands = [] | |
| 184 | + cands = [r for r in cands if r["lat"] is not None] | |
| 185 | + cands.sort(key=lambda r: _meters(lat, lng, r["lat"], r["lng"])) | |
| 186 | + has_civ = bool(_addr_parts(address)[0]) | |
| 187 | + # ≤20 m du plus proche = même propriété (seulement si l'annonce a un | |
| 188 | + # n° civique : un terrain géocodé près d'une maison ne doit pas hériter | |
| 189 | + # de la valeur du voisin) | |
| 190 | + if (cands and has_civ | |
| 191 | + and _meters(lat, lng, cands[0]["lat"], cands[0]["lng"]) <= 20): | |
| 192 | + return _pack(cands[0]) | |
| 193 | + for r in cands: | |
| 194 | + dm = _meters(lat, lng, r["lat"], r["lng"]) | |
| 195 | + civ_ok, rue_ok, muni_ok = _concordance(address, city, r) | |
| 196 | + # au-delà de 20 m : le CIVIQUE doit concorder (la « même rue » seule | |
| 197 | + # apparie l'immeuble d'à côté → mauvais merge) | |
| 198 | + if civ_ok and muni_ok and (dm <= 100 or rue_ok): | |
| 199 | + return _pack(r) | |
| 200 | + | |
| 201 | + # 2) repli : recherche d'adresse FTS stricte (avec relances) | |
| 202 | + civs, words = _addr_parts(address) | |
| 203 | + tries = [] | |
| 204 | + for c in (civs[::-1] or [""]): # civique(s), du plus probable au moins | |
| 205 | + parts = ([c] if c else []) + words | |
| 206 | + if parts: | |
| 207 | + tries.append(" AND ".join(f'"{p}"' for p in parts)) | |
| 208 | + if civs and words: | |
| 209 | + tries.append(" AND ".join(f'"{p}"' for p in words)) # rue seule (dernier recours) | |
| 210 | + seen = set() | |
| 211 | + for q in tries: | |
| 212 | + if q in seen: | |
| 213 | + continue | |
| 214 | + seen.add(q) | |
| 215 | + try: | |
| 216 | + rows = vp.execute( | |
| 217 | + f"SELECT u.{_COLS.replace(', ', ', u.')} FROM units_fts f" | |
| 218 | + " JOIN units u ON u.rowid=f.rowid WHERE units_fts MATCH ? LIMIT 50", | |
| 219 | + (q,)).fetchall() | |
| 220 | + except sqlite3.OperationalError: | |
| 221 | + return None | |
| 222 | + for r in rows: | |
| 223 | + civ_ok, rue_ok, muni_ok = _concordance(address, city, r) | |
| 224 | + if civ_ok and rue_ok and muni_ok: | |
| 225 | + return _pack(r) | |
| 226 | + return None | |
| 227 | + | |
| 228 | + | |
| 229 | +def run(limit: int | None = None, revalidate: bool = False) -> dict: | |
| 230 | + """Apparie les annonces à la base Vrai-Prix : remplit lat/lng (si manquant) | |
| 231 | + et l'estimation. `limit` borne le nombre d'annonces traitées. | |
| 232 | + `revalidate=True` : re-vérifie AUSSI les annonces déjà appariées (corrige les | |
| 233 | + mauvais merges après durcissement du matcher).""" | |
| 234 | + if not available(): | |
| 235 | + print(f"[immo-ka] vraiprix_local: base absente ({VP_DB})") | |
| 236 | + return {"error": "vraiprix.db absent"} | |
| 237 | + vp = sqlite3.connect(f"file:{VP_DB}?mode=ro", uri=True) | |
| 238 | + vp.row_factory = sqlite3.Row | |
| 239 | + con = db.connect() | |
| 240 | + where = ("active=1 AND dup_hidden=0 AND address<>''" if revalidate | |
| 241 | + else "active=1 AND dup_hidden=0 AND address<>''" | |
| 242 | + " AND (vraiprix IS NULL OR vraiprix='{}')") | |
| 243 | + rows = con.execute( | |
| 244 | + f"SELECT uid, address, city, lat, lng FROM listings WHERE {where}" | |
| 245 | + " ORDER BY first_seen DESC").fetchall() | |
| 246 | + if limit is not None: | |
| 247 | + rows = rows[:limit] | |
| 248 | + | |
| 249 | + matched = geoloc = miss = 0 | |
| 250 | + for i, r in enumerate(rows): | |
| 251 | + m = _match(vp, r["address"], r["city"], r["lat"], r["lng"]) | |
| 252 | + try: | |
| 253 | + if m and m["value"]: | |
| 254 | + est = {k: m[k] for k in ("id", "value", "low", "high", | |
| 255 | + "confidence", "confidence_pct", "url")} | |
| 256 | + con.execute("UPDATE listings SET vraiprix=? WHERE uid=?", | |
| 257 | + (json.dumps(est, ensure_ascii=False), r["uid"])) | |
| 258 | + # géocodage gratuit : coordonnées de l'unité si l'annonce n'en a pas | |
| 259 | + if (r["lat"] is None and m["lat"] is not None | |
| 260 | + and _BBOX[0] <= m["lat"] <= _BBOX[1] | |
| 261 | + and _BBOX[2] <= m["lng"] <= _BBOX[3]): | |
| 262 | + con.execute("UPDATE listings SET lat=?, lng=? WHERE uid=?", | |
| 263 | + (m["lat"], m["lng"], r["uid"])) | |
| 264 | + geoloc += 1 | |
| 265 | + matched += 1 | |
| 266 | + else: | |
| 267 | + con.execute("UPDATE listings SET vraiprix='{}' WHERE uid=?", (r["uid"],)) | |
| 268 | + miss += 1 | |
| 269 | + if i % 200 == 0: | |
| 270 | + con.commit() | |
| 271 | + except sqlite3.OperationalError: | |
| 272 | + try: | |
| 273 | + con.rollback() | |
| 274 | + except sqlite3.Error: | |
| 275 | + pass | |
| 276 | + time.sleep(1.0) | |
| 277 | + con.commit() | |
| 278 | + con.close() | |
| 279 | + vp.close() | |
| 280 | + out = {"matched": matched, "geolocated": geoloc, "no_match": miss} | |
| 281 | + print(f"[immo-ka] vraiprix_local {out}") | |
| 282 | + return out | |
modified
immoka/web.py
+4 −1
@@ -40,7 +40,10 @@ _sync_lock = threading.Lock() | ||
| 40 | 40 | # db.refresh_dedup après chaque sync) marque les doublons de sous-agences. La |
| 41 | 41 | # lecture est ainsi instantanée (index) au lieu d'un sous-select corrélé par |
| 42 | 42 | # ligne (~300 s sur 75 k lignes). Voir db.refresh_dedup pour la règle. |
| 43 | −DEDUP_CLAUSE = " AND dup_hidden=0" | |
| 43 | +# + `price IS NOT NULL` : on masque partout les annonces « Prix sur demande » | |
| 44 | +# (sans prix) — grille, carte, stats, sources. La fiche détail par uid reste | |
| 45 | +# accessible en direct. | |
| 46 | +DEDUP_CLAUSE = " AND dup_hidden=0 AND price IS NOT NULL" | |
| 44 | 47 | |
| 45 | 48 | |
| 46 | 49 | def _row_to_dict(row) -> dict: |
modified
run.py
+8 −0
@@ -44,6 +44,9 @@ def main() -> None: | ||
| 44 | 44 | print(sid) |
| 45 | 45 | print(f"-- {len(CONNECTORS)} connecteur(s)") |
| 46 | 46 | elif cmd == "geocode": |
| 47 | + from immoka import geocode | |
| 48 | + geocode.run_batch(int(sys.argv[2]) if len(sys.argv) > 2 else None) | |
| 49 | + elif cmd == "geocode1": # ancien mode 1-par-1 (+ repli Nominatim) | |
| 47 | 50 | from immoka import geocode |
| 48 | 51 | geocode.run(int(sys.argv[2]) if len(sys.argv) > 2 else None) |
| 49 | 52 | elif cmd == "poi": |
@@ -55,6 +58,11 @@ def main() -> None: | ||
| 55 | 58 | elif cmd == "vraiprix": |
| 56 | 59 | from immoka import vraiprix |
| 57 | 60 | vraiprix.run(int(sys.argv[2]) if len(sys.argv) > 2 else None) |
| 61 | + elif cmd == "vraiprix_local": # appariement local (vraiprix.db) : rapide | |
| 62 | + from immoka import vraiprix_local | |
| 63 | + reval = "reval" in sys.argv[2:] | |
| 64 | + lim = next((int(a) for a in sys.argv[2:] if a.isdigit()), None) | |
| 65 | + vraiprix_local.run(lim, revalidate=reval) | |
| 58 | 66 | elif cmd == "serve": |
| 59 | 67 | import uvicorn |
| 60 | 68 | port = int(sys.argv[2]) if len(sys.argv) > 2 else 8090 |
| 61 | 69 | |