# ----------------------------------------------------------------------------- # Immo-Ka — Agrégateur de propriétés à vendre (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # vraiprix_local.py : appariement LOCAL contre la base Vrai-Prix (vraiprix.db, # 3,7 M unités d'évaluation avec adresse, lat/lng, estimation, fourchette). # Une jointure d'adresse (FTS) par annonce → remplit d'un coup : # · lat/lng manquants (géocodage instantané, sans API externe) # · l'estimation Vrai-Prix (valeur + P10-P90 + lien /estimation/{id}) # Bien plus rapide que l'API vrai-prix (une requête réseau par annonce). # ----------------------------------------------------------------------------- from __future__ import annotations import json import os import re import sqlite3 import time import unicodedata from . import db # Emplacement de la base Vrai-Prix (copiée à côté de immoka.db sur le nœud). VP_DB = os.environ.get( "VRAIPRIX_DB", str((__import__("pathlib").Path(__file__).resolve().parent.parent / "data" / "vraiprix.db"))) SITE = "https://www.vrai-prix.com" _BBOX = (44.5, 63.0, -80.0, -56.0) # Québec def _norm(s: str) -> str: return "".join(c for c in unicodedata.normalize("NFD", (s or "").lower()) if unicodedata.category(c) != "Mn").strip() # mots de « type de voie » ignorés dans l'appariement (variabilité rue/av/boul…) _VOIE = {"rue", "av", "ave", "avenue", "boul", "boulevard", "ch", "chemin", "place", "pl", "rang", "montee", "montée", "cote", "côte", "route", "rte", "terrasse", "impasse", "croissant", "cours", "allee", "allée", "de", "du", "des", "la", "le", "les", "l", "d", "st", "ste", "saint", "sainte"} def _street_words(norm_addr: str) -> set: """Mots significatifs de la rue (sans n° civique, type de voie, n° d'app.).""" a = re.sub(r"\b(?:app?t?|appartement|unite|suite|local|bureau|#)\s*[\w-]+", " ", norm_addr.split(",")[0]) a = re.sub(r"[^a-z0-9 ]+", " ", a) toks = [t for t in a.split() if t] return {t for t in toks if not t.isdigit() and t not in _VOIE and len(t) > 1} def _fts_query(address: str) -> tuple[str, str]: """Construit une requête FTS AND (n° civique + mots significatifs de la rue, sans le type de voie ni le n° d'appartement) + retourne le civique.""" a = _norm(address).split(",")[0] a = re.sub(r"\b(?:app?t?|appartement|unite|suite|local|bureau|#)\s*[\w-]+", " ", a) a = re.sub(r"[^a-z0-9 ]+", " ", a) toks = [t for t in a.split() if t] civ = toks[0] if toks and toks[0].isdigit() else "" words = [t for t in toks if t != civ and t not in _VOIE and len(t) > 1] parts = ([civ] if civ else []) + words if not parts: return "", civ return " AND ".join(f'"{p}"' for p in parts), civ def available() -> bool: return os.path.exists(VP_DB) def _match(vp: sqlite3.Connection, address: str, city: str) -> dict | None: """Meilleure unité Vrai-Prix pour (adresse, ville).""" # n° civique + mots significatifs de la rue (AND), filtre municipalité q, civ = _fts_query(address) if not q: return None try: rows = vp.execute( "SELECT u.id_provinc, u.adresse, u.municipalite, u.lat, u.lng," " u.est_hedo, u.est_2026, u.p10, u.p90" " FROM units_fts f JOIN units u ON u.rowid=f.rowid" " WHERE units_fts MATCH ? LIMIT 25", (q,)).fetchall() except sqlite3.OperationalError: return None if not rows: return None # VALIDATION STRICTE : on n'accepte QUE si même adresse — n° civique # identique + au moins un mot de rue en commun + même municipalité. Sinon # PAS de match (mieux vaut aucune estimation qu'une mauvaise). iwords = _street_words(_norm(address)) nc = _norm(city).split()[0] if city else "" def _accept(r) -> bool: ua = _norm(r["adresse"]) m = re.match(r"\s*(\d+)", ua) if not civ or not m or m.group(1) != civ: # même n° civique return False uwords = _street_words(ua) if iwords and not (iwords & uwords): # mot de rue commun return False um = _norm(r["municipalite"] or "") if nc and um and nc not in um and um.split()[0] not in _norm(city): return False # même municipalité return True best = next((r for r in rows if _accept(r)), None) if best is None: return None lat, lng = best["lat"], best["lng"] val = best["est_hedo"] or best["est_2026"] return { "id": best["id_provinc"], "lat": lat, "lng": lng, "value": val, "low": best["p10"], "high": best["p90"], "confidence": None, "confidence_pct": None, "url": f"{SITE}/estimation/{best['id_provinc']}", } def run(limit: int | None = None, revalidate: bool = False) -> dict: """Apparie les annonces à la base Vrai-Prix : remplit lat/lng (si manquant) et l'estimation. `limit` borne le nombre d'annonces traitées. `revalidate=True` : re-vérifie AUSSI les annonces déjà appariées (corrige les mauvais merges après durcissement du matcher).""" if not available(): print(f"[immo-ka] vraiprix_local: base absente ({VP_DB})") return {"error": "vraiprix.db absent"} vp = sqlite3.connect(f"file:{VP_DB}?mode=ro", uri=True) vp.row_factory = sqlite3.Row con = db.connect() where = ("active=1 AND dup_hidden=0 AND address<>''" if revalidate else "active=1 AND dup_hidden=0 AND address<>''" " AND (vraiprix IS NULL OR vraiprix='{}')") rows = con.execute( f"SELECT uid, address, city, lat FROM listings WHERE {where}" " ORDER BY first_seen DESC").fetchall() if limit is not None: rows = rows[:limit] matched = geoloc = miss = 0 for i, r in enumerate(rows): m = _match(vp, r["address"], r["city"]) try: if m and m["value"]: est = {k: m[k] for k in ("id", "value", "low", "high", "confidence", "confidence_pct", "url")} con.execute("UPDATE listings SET vraiprix=? WHERE uid=?", (json.dumps(est, ensure_ascii=False), r["uid"])) # géocodage gratuit : coordonnées de l'unité si l'annonce n'en a pas if (r["lat"] is None and m["lat"] is not None and _BBOX[0] <= m["lat"] <= _BBOX[1] and _BBOX[2] <= m["lng"] <= _BBOX[3]): con.execute("UPDATE listings SET lat=?, lng=? WHERE uid=?", (m["lat"], m["lng"], r["uid"])) geoloc += 1 matched += 1 else: con.execute("UPDATE listings SET vraiprix='{}' WHERE uid=?", (r["uid"],)) miss += 1 if i % 200 == 0: con.commit() except sqlite3.OperationalError: try: con.rollback() except sqlite3.Error: pass time.sleep(1.0) con.commit() con.close() vp.close() out = {"matched": matched, "geolocated": geoloc, "no_match": miss} print(f"[immo-ka] vraiprix_local {out}") return out