SPB Git

spb/immo-ka Public

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

Python 66.4% TypeScript 19.9% CSS 13.2% HTML 0.5%
6.3 KB · 167 lines python
Raw Blame History
1# -----------------------------------------------------------------------------2# Immo-Ka — Agrégateur de maisons à vendre (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# connectors/_detailutil.py : utilitaires partagés d'enrichissement « page détail »5#   Mutualise ce que chaque connecteur d'agence répète pour capter TOUTES les6#   infos de la fiche source (comme remax_quebec.py) : description JSON-LD,7#   coordonnées, aplatissement HTML, application au PropertyListing.8# -----------------------------------------------------------------------------9from __future__ import annotations1011import html as _html12import json13import re1415from ..schema import PropertyListing1617_LD_RE = re.compile(r'<script[^>]+application/ld\+json[^>]*>(.*?)</script>', re.S | re.I)18_COORD_RE = re.compile(r'(?:google\.[^"\']*?[?&](?:q|query|ll|center)=|maps/@)'19                       r'(-?\d{1,2}\.\d+)[ ,%+A-Za-z]+?(-?\d{2,3}\.\d+)')20_LD_PROP_TYPES = {"RealEstateListing", "Residence", "SingleFamilyResidence",21                  "House", "Apartment", "Product", "Offer", "Place", "Accommodation"}222324def ld_nodes(html: str):25    """Itère les objets JSON-LD (aplatis depuis @graph)."""26    for block in _LD_RE.findall(html):27        try:28            data = json.loads(block)29        except ValueError:30            continue31        graph = data.get("@graph", [data]) if isinstance(data, dict) else data32        for node in (graph if isinstance(graph, list) else [graph]):33            if isinstance(node, dict):34                yield node353637def ld_description(html: str) -> str:38    """Description depuis un nœud JSON-LD de type propriété (le plus long trouvé)."""39    best = ""40    for n in ld_nodes(html):41        t = n.get("@type")42        types = t if isinstance(t, list) else [t]43        if any(x in _LD_PROP_TYPES for x in types) and n.get("description"):44            d = _html.unescape(str(n["description"])).strip()45            if len(d) > len(best):46                best = d47    return best484950def gmaps_coords(html: str) -> tuple[float, float] | None:51    m = _COORD_RE.search(html)52    if not m:53        return None54    try:55        lat, lng = float(m.group(1)), float(m.group(2))56    except ValueError:57        return None58    if 44.5 <= lat <= 63.0 and -80.0 <= lng <= -56.0:59        return lat, lng60    return None616263def flatten(html: str) -> str:64    """HTML -> texte « valeur | libellé » pour extraire les tableaux Centris."""65    t = _html.unescape(re.sub(r"<[^>]+>", " | ", html))66    t = re.sub(r"[ \t\r\n]*\|[ \t\r\n|]*", " | ", t)67    return re.sub(r"[ \t]+", " ", t)686970# libellés Centris standard cherchés dans « valeur | libellé » OU « libellé | valeur »71_LABELS = [72    "Type de propriété", "Genre de propriété", "Style de bâtiment",73    "Année de construction", "Superficie habitable", "Superficie du terrain",74    "Superficie du bâtiment (au sol)", "Nombre de pièces", "Nombre d'unités",75    "Stationnement (total)", "Stationnement", "Garage", "Système de chauffage",76    "Énergie pour le chauffage", "Type de fenestration", "Fenêtres", "Toiture",77    "Revêtement", "Sous-sol", "Piscine", "Zonage", "Système d'égouts",78    "Approvisionnement en eau", "Déménagement", "Taxes municipales",79    "Taxes scolaires", "Évaluation municipale (terrain)",80    "Évaluation municipale (bâtiment)", "Cuisine",81]828384def centris_details(text: str) -> dict:85    """Extrait les caractéristiques Centris d'un texte aplati (les deux ordres)."""86    out: dict = {}87    for label in _LABELS:88        lab = re.escape(label)89        m = (re.search(r"([^|]{1,55})\s*\|\s*" + lab + r"\b", text)90             or re.search(lab + r"\b\s*\|\s*([^|]{1,55})", text))91        if m:92            val = m.group(1).strip(" |")93            if val and 1 <= len(val) <= 55 and val.lower() != label.lower():94                out[label] = val95    return out969798_INT_RE = re.compile(r"\d+")99100101def _int(v):102    if v is None:103        return None104    if isinstance(v, (int, float)):105        return int(v) or None106    m = _INT_RE.search(str(v))107    return int(m.group()) if m else None108109110def enrich(connector, listings, limit, parse_fn, key="v1", fetch_html=None):111    """Enrichit `listings` via leur page détail, avec cache BD + plafond `limit`.112113    - `parse_fn(html) -> dict` : extrait les champs riches d'une page détail.114    - `key` : versionne le cache (changer pour forcer un rafraîchissement).115    - `fetch_html(url) -> str` : par défaut connector.get(url).text ; passer116      connector.get_rendered pour les sites derrière Firecrawl/anti-bot.117    """118    if limit <= 0:119        return120    from .. import db121    fetch_html = fetch_html or (lambda u: connector.get(u).text)122    con = db.connect()123    budget = limit124    try:125        for lst in listings:126            cached = db.get_cached_detail(con, connector.source_id, lst.external_id, key)127            if cached is None:128                if budget <= 0:129                    continue130                try:131                    cached = parse_fn(fetch_html(lst.url))132                except Exception:133                    cached = {}134                db.put_cached_detail(con, connector.source_id, lst.external_id, key, cached)135                budget -= 1136            apply_detail(lst, cached)137    finally:138        con.close()139140141def apply_detail(lst: PropertyListing, d: dict) -> None:142    """Applique un payload détail au PropertyListing sans écraser les valeurs déjà143    présentes (sauf images : on garde la plus grande galerie)."""144    if not d:145        return146    imgs = d.get("images")147    if imgs and len(imgs) > len(lst.images):148        lst.images = imgs149    if d.get("features"):150        # fusionne en dédoublonnant151        seen = {f.lower() for f in lst.features}152        for f in d["features"]:153            if f.lower() not in seen:154                lst.features.append(f)155                seen.add(f.lower())156    if d.get("details"):157        lst.details.update(d["details"])158    if d.get("broker_name"):159        lst.broker_name = d["broker_name"]160    for f in ("description", "price_label"):161        if d.get(f) and not getattr(lst, f, ""):162            setattr(lst, f, d[f])163    for f in ("bedrooms", "bathrooms", "powder_rooms", "year_built",164              "area_sqft", "lot_sqft", "lat", "lng", "broker_phone", "price"):165        if d.get(f) is not None and getattr(lst, f, None) in (None, "", 0):166            setattr(lst, f, d[f])167