# ----------------------------------------------------------------------------- # 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", "bd", "blvd", "ch", "chemin", "place", "pl", "rang", "rangs", "rg", "montee", "montée", "mtee", "cote", "côte", "route", "rte", "terrasse", "tsse", "ter", "impasse", "imp", "croissant", "crois", "croiss", "cours", "allee", "allée", "prom", "promenade", "carre", "aut", "autoroute", "de", "du", "des", "la", "le", "les", "l", "d", "et", "sur", "est", "ouest", "nord", "sud", "st", "ste", "saint", "sainte"} _APP_RE = r"\b(?:app?t?|appartement|unite|suite|local|bureau|#)\s*[\w-]+" def _street_words(norm_addr: str) -> set: """Mots significatifs de la rue (sans n° civique, type de voie, n° d'app.).""" a = re.sub(_APP_RE, " ", norm_addr.split(",")[0]) a = re.sub(r"[^a-z0-9 ]+", " ", a) a = re.sub(r"^\s*\d+[a-z]{0,2}(?:\s+\d+)?\s+", " ", a) # civique(s) en tête toks = [t for t in a.split() if t] return {t for t in toks if t not in _VOIE and len(t) > 1} def _addr_parts(address: str) -> tuple[list, list]: """→ (civiques candidats, mots de rue). Gère « 822Z » (suffixe de lettre), « 102 50 Rue X » (app-civique : les deux nombres sont candidats) et conserve les rues numériques (« Route 202 », « 117e Avenue »).""" a = _norm(address).split(",")[0] a = re.sub(_APP_RE, " ", a) a = re.sub(r"[^a-z0-9 ]+", " ", a) toks = [t for t in a.split() if t] civs = [] while toks and len(civs) < 2: m = re.match(r"^(\d+)[a-z]{0,2}$", toks[0]) if not m: break if civs and not toks[0].isdigit(): # ordinal de rue (2e, 3e…) : garder break civs.append(m.group(1)) toks = toks[1:] words = [t for t in toks if t not in _VOIE and len(t) > 1] return civs, words def _fts_query(address: str) -> tuple[str, str]: """Requête FTS AND (n° civique + mots significatifs de la rue).""" civs, words = _addr_parts(address) civ = civs[-1] if civs else "" 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 _pack(r) -> dict: d = { "id": r["id_provinc"], "lat": r["lat"], "lng": r["lng"], "value": r["est_hedo"] or r["est_2026"], "low": r["p10"], "high": r["p90"], "confidence": None, "confidence_pct": None, "url": f"{SITE}/estimation/{r['id_provinc']}", } d.update(_role_fields(r)) return d # clés « rôle d'évaluation » ajoutées au JSON vraiprix (valeurs officielles) ROLE_KEYS = ("valeur_role", "valeur_terrain", "valeur_batiment", "annee_construction_role", "superficie_terrain_role_m2", "aire_etages_role_m2") def _role_fields(r) -> dict: """Champs du rôle d'évaluation foncière de l'unité appariée (officiels) : valeurs (rôle/terrain/bâtiment), année de construction et superficies.""" out = {} for src, dst in (("valeur_role", "valeur_role"), ("valeur_terrain", "valeur_terrain"), ("valeur_batiment", "valeur_batiment"), ("annee_construction", "annee_construction_role"), ("superficie_terrain_m2", "superficie_terrain_role_m2"), ("aire_etages_m2", "aire_etages_role_m2")): try: v = r[src] except (KeyError, IndexError): v = None if v: out[dst] = v return out # le terrain du rôle d'une COPROPRIÉTÉ est souvent celui de l'immeuble entier : # jamais de repli lot_sqft pour ces types _NO_LOT_TYPES = ("condo", "appartement", "loft", "copropriete") def _apply_role_fallback(con, uid: str, year_built, lot_sqft, property_type: str, est: dict) -> tuple[int, int]: """Repli des COLONNES depuis le rôle quand la source ne fournit rien : year_built ← annee_construction_role, lot_sqft ← superficie_terrain_role_m2 (sauf copropriétés). Provenance marquée dans details.*_source='role'. Retourne (année_remplie, terrain_rempli) ∈ {0,1}².""" fy = fl = 0 y = est.get("annee_construction_role") if year_built is None and y and 1600 <= int(y) <= 2049: con.execute( "UPDATE listings SET year_built=?," " details=json_set(COALESCE(details,'{}'),'$.year_built_source','role')" " WHERE uid=? AND year_built IS NULL", (int(y), uid)) fy = 1 t = est.get("superficie_terrain_role_m2") pt = _norm(property_type or "") if (lot_sqft is None and t and float(t) > 0 and not any(k in pt for k in _NO_LOT_TYPES)): con.execute( "UPDATE listings SET lot_sqft=?," " details=json_set(COALESCE(details,'{}'),'$.lot_sqft_source','role')" " WHERE uid=? AND lot_sqft IS NULL", (round(float(t) * 10.7639), uid)) fl = 1 return fy, fl def _meters(a1: float, o1: float, a2: float, o2: float) -> float: """Distance approx. en mètres (équirectangulaire, ~exact à courte portée).""" import math dlat = (a2 - a1) * 111_000.0 dlng = (o2 - o1) * 111_000.0 * math.cos(math.radians(a1)) return (dlat * dlat + dlng * dlng) ** 0.5 # mots génériques ignorés dans la comparaison de municipalités _MUNI_GEN = {"saint", "sainte", "ville", "de", "du", "des", "la", "le", "les", "sur", "au", "aux", "lac", "notre", "dame", "canton", "cantons", "municipalite", "paroisse", "village", "mont"} def _muni_norm(s: str) -> str: s = re.sub(r"[^a-z0-9 ]+", " ", _norm(s or "")) s = re.sub(r"\bst\b", "saint", s) s = re.sub(r"\bste\b", "sainte", s) return " ".join(s.split()) def _muni_one(nc: str, um: str) -> bool: if nc in um or um in nc: return True return bool((set(nc.split()) - _MUNI_GEN) & (set(um.split()) - _MUNI_GEN)) def _muni_match(city: str, unit_muni: str, address: str = "") -> bool: """Ville de l'annonce vs municipalité du rôle — tolère St/Ste, accents, arrondissement (« Gatineau Aylmer ») et la ville glissée dans l'adresse (« 1119 Ch. Dunant, Sainte-Anne-des-Lacs » avec city=« Laurentides »).""" nc = _muni_norm(city) if not nc: return True um = _muni_norm(unit_muni) if not um: return False cands = [nc] + [_muni_norm(p) for p in _norm(address).split(",")[1:]] return any(c and _muni_one(c, um) for c in cands) def _concordance(address: str, city: str, r) -> tuple[bool, bool, bool]: """(civique_ok, rue_ok, municipalité_ok) entre l'annonce et l'unité.""" civs, words = _addr_parts(address) ua = _norm(r["adresse"]) civ_ok = False mr = re.match(r"\s*(\d+)\s*-\s*(\d+)", ua) ms = re.match(r"\s*(\d+)", ua) for c in civs: ci = int(c) if mr: lo, hi = int(mr.group(1)), int(mr.group(2)) civ_ok = min(lo, hi) <= ci <= max(lo, hi) elif ms: civ_ok = ms.group(1) == c if civ_ok: break iwords = set(words) rue_ok = bool(iwords) and bool(iwords & _street_words(ua)) muni_ok = _muni_match(city, r["municipalite"] or "", address) return civ_ok, rue_ok, muni_ok _COLS = ("id_provinc, adresse, municipalite, lat, lng, est_hedo, est_2026, p10, p90, " "annee_construction, aire_etages_m2, superficie_terrain_m2, " "valeur_terrain, valeur_batiment, valeur_role") def _match(vp: sqlite3.Connection, address: str, city: str, lat: float | None = None, lng: float | None = None) -> dict | None: """Apparie une annonce à une unité Vrai-Prix par : 1) PROXIMITÉ SPATIALE (si lat/lng) — l'unité la plus proche, validée par l'adresse (text mining : civique/rue/municipalité) ; 2) sinon recherche d'adresse FTS stricte. Ne retourne un match que s'il est fiable (mieux vaut rien qu'un faux).""" # 1) spatial + text-mining : candidats triés par distance if lat is not None and lng is not None: d = 0.0022 # ~±250 m try: cands = vp.execute( f"SELECT {_COLS} FROM units" " WHERE lat BETWEEN ? AND ? AND lng BETWEEN ? AND ?", (lat - d, lat + d, lng - d, lng + d)).fetchall() except sqlite3.OperationalError: cands = [] cands = [r for r in cands if r["lat"] is not None] cands.sort(key=lambda r: _meters(lat, lng, r["lat"], r["lng"])) has_civ = bool(_addr_parts(address)[0]) # ≤20 m du plus proche = même propriété (seulement si l'annonce a un # n° civique : un terrain géocodé près d'une maison ne doit pas hériter # de la valeur du voisin) if (cands and has_civ and _meters(lat, lng, cands[0]["lat"], cands[0]["lng"]) <= 20): return _pack(cands[0]) for r in cands: dm = _meters(lat, lng, r["lat"], r["lng"]) civ_ok, rue_ok, muni_ok = _concordance(address, city, r) # au-delà de 20 m : le CIVIQUE doit concorder (la « même rue » seule # apparie l'immeuble d'à côté → mauvais merge) if civ_ok and muni_ok and (dm <= 100 or rue_ok): return _pack(r) # 2) repli : recherche d'adresse FTS stricte (avec relances) civs, words = _addr_parts(address) tries = [] for c in (civs[::-1] or [""]): # civique(s), du plus probable au moins parts = ([c] if c else []) + words if parts: tries.append(" AND ".join(f'"{p}"' for p in parts)) if civs and words: tries.append(" AND ".join(f'"{p}"' for p in words)) # rue seule (dernier recours) seen = set() for q in tries: if q in seen: continue seen.add(q) try: rows = vp.execute( f"SELECT u.{_COLS.replace(', ', ', u.')} FROM units_fts f" " JOIN units u ON u.rowid=f.rowid WHERE units_fts MATCH ? LIMIT 50", (q,)).fetchall() except sqlite3.OperationalError: return None for r in rows: civ_ok, rue_ok, muni_ok = _concordance(address, city, r) if civ_ok and rue_ok and muni_ok: return _pack(r) return None 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, lng, year_built, lot_sqft," f" property_type 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"], r["lat"], r["lng"]) try: if m and m["value"]: est = {k: m[k] for k in ("id", "value", "low", "high", "confidence", "confidence_pct", "url")} est.update({k: m[k] for k in ROLE_KEYS if m.get(k)}) con.execute("UPDATE listings SET vraiprix=? WHERE uid=?", (json.dumps(est, ensure_ascii=False), r["uid"])) # repli des colonnes depuis le rôle (année, terrain) _apply_role_fallback(con, r["uid"], r["year_built"], r["lot_sqft"], r["property_type"], est) # 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 def enrich_role(limit: int | None = None) -> dict: """Backfill du rôle d'évaluation pour les annonces DÉJÀ appariées dont le JSON vraiprix ne porte pas encore les champs officiels (valeur_role, annee_construction_role, superficies…). Aucun ré-appariement : jointure directe par id_provinc (clé primaire) — quasi gratuit. Applique aussi les replis de colonnes (year_built/lot_sqft) marqués `*_source='role'`.""" if not available(): return {"error": "vraiprix.db absent"} vp = sqlite3.connect(f"file:{VP_DB}?mode=ro", uri=True) vp.row_factory = sqlite3.Row con = db.connect() rows = con.execute( "SELECT uid, vraiprix, year_built, lot_sqft, property_type FROM listings" " WHERE active=1 AND vraiprix LIKE '%\"id\"%'" " AND vraiprix NOT LIKE '%valeur_role%'" " AND vraiprix NOT LIKE '%role_checked%'").fetchall() if limit is not None: rows = rows[:limit] enriched = fallback_y = fallback_l = 0 for i, r in enumerate(rows): try: est = json.loads(r["vraiprix"]) or {} except (TypeError, ValueError): continue if not est.get("id"): continue u = vp.execute( "SELECT annee_construction, aire_etages_m2, superficie_terrain_m2," " valeur_terrain, valeur_batiment, valeur_role" " FROM units WHERE id_provinc=?", (str(est["id"]),)).fetchone() try: if u is None: est["role_checked"] = 1 # unité disparue : ne pas repasser else: fields = _role_fields(u) if not fields: est["role_checked"] = 1 # unité sans données de rôle est.update(fields) fy, fl = _apply_role_fallback(con, r["uid"], r["year_built"], r["lot_sqft"], r["property_type"], est) fallback_y += fy fallback_l += fl con.execute("UPDATE listings SET vraiprix=? WHERE uid=?", (json.dumps(est, ensure_ascii=False), r["uid"])) enriched += 1 if i % 500 == 0: con.commit() except sqlite3.OperationalError: try: con.rollback() except sqlite3.Error: pass time.sleep(1.0) con.commit() # réparation set-based (idempotente, instantanée) : ré-applique le repli de # colonnes depuis le JSON vraiprix déjà enrichi — filet de sécurité si une # mise à jour de source a écrasé year_built/lot_sqft (anciens process sans # COALESCE, edge cases de course entre writer et backfill). cur = con.execute( "UPDATE listings SET" " year_built=CAST(json_extract(vraiprix,'$.annee_construction_role') AS INT)," " details=json_set(COALESCE(details,'{}'),'$.year_built_source','role')" " WHERE active=1 AND year_built IS NULL" " AND CAST(json_extract(vraiprix,'$.annee_construction_role') AS INT)" " BETWEEN 1600 AND 2049") repaired_y = cur.rowcount like_condo = ("lower(property_type) LIKE '%condo%'" " OR lower(property_type) LIKE '%appartement%'" " OR lower(property_type) LIKE '%loft%'" " OR lower(property_type) LIKE '%copropri%'") cur = con.execute( "UPDATE listings SET" " lot_sqft=ROUND(CAST(json_extract(vraiprix,'$.superficie_terrain_role_m2')" " AS REAL) * 10.7639)," " details=json_set(COALESCE(details,'{}'),'$.lot_sqft_source','role')" " WHERE active=1 AND lot_sqft IS NULL" " AND CAST(json_extract(vraiprix,'$.superficie_terrain_role_m2') AS REAL) > 0" f" AND NOT ({like_condo})") repaired_l = cur.rowcount con.commit() con.close() vp.close() out = {"role_enriched": enriched, "year_built_filled": fallback_y, "lot_filled": fallback_l, "repaired_year": repaired_y, "repaired_lot": repaired_l} print(f"[immo-ka] vraiprix_local.enrich_role {out}") return out