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%
7.2 KB · 174 lines python
Raw Blame History
1# -----------------------------------------------------------------------------2# Immo-Ka — Agrégateur de propriétés à vendre (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# 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# -----------------------------------------------------------------------------11from __future__ import annotations1213import json14import os15import re16import sqlite317import time18import unicodedata1920from . import db2122# Emplacement de la base Vrai-Prix (copiée à côté de immoka.db sur le nœud).23VP_DB = os.environ.get(24    "VRAIPRIX_DB",25    str((__import__("pathlib").Path(__file__).resolve().parent.parent26         / "data" / "vraiprix.db")))27SITE = "https://www.vrai-prix.com"28_BBOX = (44.5, 63.0, -80.0, -56.0)   # Québec293031def _norm(s: str) -> str:32    return "".join(c for c in unicodedata.normalize("NFD", (s or "").lower())33                   if unicodedata.category(c) != "Mn").strip()343536# mots de « type de voie » ignorés dans l'appariement (variabilité rue/av/boul…)37_VOIE = {"rue", "av", "ave", "avenue", "boul", "boulevard", "ch", "chemin",38         "place", "pl", "rang", "montee", "montée", "cote", "côte", "route",39         "rte", "terrasse", "impasse", "croissant", "cours", "allee", "allée",40         "de", "du", "des", "la", "le", "les", "l", "d", "st", "ste", "saint",41         "sainte"}424344def _street_words(norm_addr: str) -> set:45    """Mots significatifs de la rue (sans n° civique, type de voie, n° d'app.)."""46    a = re.sub(r"\b(?:app?t?|appartement|unite|suite|local|bureau|#)\s*[\w-]+", " ",47               norm_addr.split(",")[0])48    a = re.sub(r"[^a-z0-9 ]+", " ", a)49    toks = [t for t in a.split() if t]50    return {t for t in toks if not t.isdigit() and t not in _VOIE and len(t) > 1}515253def _fts_query(address: str) -> tuple[str, str]:54    """Construit une requête FTS AND (n° civique + mots significatifs de la rue,55    sans le type de voie ni le n° d'appartement) + retourne le civique."""56    a = _norm(address).split(",")[0]57    a = re.sub(r"\b(?:app?t?|appartement|unite|suite|local|bureau|#)\s*[\w-]+", " ", a)58    a = re.sub(r"[^a-z0-9 ]+", " ", a)59    toks = [t for t in a.split() if t]60    civ = toks[0] if toks and toks[0].isdigit() else ""61    words = [t for t in toks if t != civ and t not in _VOIE and len(t) > 1]62    parts = ([civ] if civ else []) + words63    if not parts:64        return "", civ65    return " AND ".join(f'"{p}"' for p in parts), civ666768def available() -> bool:69    return os.path.exists(VP_DB)707172def _match(vp: sqlite3.Connection, address: str, city: str) -> dict | None:73    """Meilleure unité Vrai-Prix pour (adresse, ville)."""74    # n° civique + mots significatifs de la rue (AND), filtre municipalité75    q, civ = _fts_query(address)76    if not q:77        return None78    try:79        rows = vp.execute(80            "SELECT u.id_provinc, u.adresse, u.municipalite, u.lat, u.lng,"81            " u.est_hedo, u.est_2026, u.p10, u.p90"82            " FROM units_fts f JOIN units u ON u.rowid=f.rowid"83            " WHERE units_fts MATCH ? LIMIT 25", (q,)).fetchall()84    except sqlite3.OperationalError:85        return None86    if not rows:87        return None88    # VALIDATION STRICTE : on n'accepte QUE si même adresse — n° civique89    # identique + au moins un mot de rue en commun + même municipalité. Sinon90    # PAS de match (mieux vaut aucune estimation qu'une mauvaise).91    iwords = _street_words(_norm(address))92    nc = _norm(city).split()[0] if city else ""9394    def _accept(r) -> bool:95        ua = _norm(r["adresse"])96        m = re.match(r"\s*(\d+)", ua)97        if not civ or not m or m.group(1) != civ:      # même n° civique98            return False99        uwords = _street_words(ua)100        if iwords and not (iwords & uwords):            # mot de rue commun101            return False102        um = _norm(r["municipalite"] or "")103        if nc and um and nc not in um and um.split()[0] not in _norm(city):104            return False                                # même municipalité105        return True106107    best = next((r for r in rows if _accept(r)), None)108    if best is None:109        return None110    lat, lng = best["lat"], best["lng"]111    val = best["est_hedo"] or best["est_2026"]112    return {113        "id": best["id_provinc"], "lat": lat, "lng": lng,114        "value": val, "low": best["p10"], "high": best["p90"],115        "confidence": None, "confidence_pct": None,116        "url": f"{SITE}/estimation/{best['id_provinc']}",117    }118119120def run(limit: int | None = None, revalidate: bool = False) -> dict:121    """Apparie les annonces à la base Vrai-Prix : remplit lat/lng (si manquant)122    et l'estimation. `limit` borne le nombre d'annonces traitées.123    `revalidate=True` : re-vérifie AUSSI les annonces déjà appariées (corrige les124    mauvais merges après durcissement du matcher)."""125    if not available():126        print(f"[immo-ka] vraiprix_local: base absente ({VP_DB})")127        return {"error": "vraiprix.db absent"}128    vp = sqlite3.connect(f"file:{VP_DB}?mode=ro", uri=True)129    vp.row_factory = sqlite3.Row130    con = db.connect()131    where = ("active=1 AND dup_hidden=0 AND address<>''" if revalidate132             else "active=1 AND dup_hidden=0 AND address<>''"133                  " AND (vraiprix IS NULL OR vraiprix='{}')")134    rows = con.execute(135        f"SELECT uid, address, city, lat FROM listings WHERE {where}"136        " ORDER BY first_seen DESC").fetchall()137    if limit is not None:138        rows = rows[:limit]139140    matched = geoloc = miss = 0141    for i, r in enumerate(rows):142        m = _match(vp, r["address"], r["city"])143        try:144            if m and m["value"]:145                est = {k: m[k] for k in ("id", "value", "low", "high",146                                         "confidence", "confidence_pct", "url")}147                con.execute("UPDATE listings SET vraiprix=? WHERE uid=?",148                            (json.dumps(est, ensure_ascii=False), r["uid"]))149                # géocodage gratuit : coordonnées de l'unité si l'annonce n'en a pas150                if (r["lat"] is None and m["lat"] is not None151                        and _BBOX[0] <= m["lat"] <= _BBOX[1]152                        and _BBOX[2] <= m["lng"] <= _BBOX[3]):153                    con.execute("UPDATE listings SET lat=?, lng=? WHERE uid=?",154                                (m["lat"], m["lng"], r["uid"]))155                    geoloc += 1156                matched += 1157            else:158                con.execute("UPDATE listings SET vraiprix='{}' WHERE uid=?", (r["uid"],))159                miss += 1160            if i % 200 == 0:161                con.commit()162        except sqlite3.OperationalError:163            try:164                con.rollback()165            except sqlite3.Error:166                pass167            time.sleep(1.0)168    con.commit()169    con.close()170    vp.close()171    out = {"matched": matched, "geolocated": geoloc, "no_match": miss}172    print(f"[immo-ka] vraiprix_local {out}")173    return out174