# ----------------------------------------------------------------------------- # Immo-Ka — Agrégateur de maisons à vendre (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/_detailutil.py : utilitaires partagés d'enrichissement « page détail » # Mutualise ce que chaque connecteur d'agence répète pour capter TOUTES les # infos de la fiche source (comme remax_quebec.py) : description JSON-LD, # coordonnées, aplatissement HTML, application au PropertyListing. # ----------------------------------------------------------------------------- from __future__ import annotations import html as _html import json import re from ..schema import PropertyListing _LD_RE = re.compile(r']+application/ld\+json[^>]*>(.*?)', re.S | re.I) _COORD_RE = re.compile(r'(?:google\.[^"\']*?[?&](?:q|query|ll|center)=|maps/@)' r'(-?\d{1,2}\.\d+)[ ,%+A-Za-z]+?(-?\d{2,3}\.\d+)') _LD_PROP_TYPES = {"RealEstateListing", "Residence", "SingleFamilyResidence", "House", "Apartment", "Product", "Offer", "Place", "Accommodation"} def ld_nodes(html: str): """Itère les objets JSON-LD (aplatis depuis @graph).""" for block in _LD_RE.findall(html): try: data = json.loads(block) except ValueError: continue graph = data.get("@graph", [data]) if isinstance(data, dict) else data for node in (graph if isinstance(graph, list) else [graph]): if isinstance(node, dict): yield node def ld_description(html: str) -> str: """Description depuis un nœud JSON-LD de type propriété (le plus long trouvé).""" best = "" for n in ld_nodes(html): t = n.get("@type") types = t if isinstance(t, list) else [t] if any(x in _LD_PROP_TYPES for x in types) and n.get("description"): d = _html.unescape(str(n["description"])).strip() if len(d) > len(best): best = d return best def gmaps_coords(html: str) -> tuple[float, float] | None: m = _COORD_RE.search(html) if not m: return None try: lat, lng = float(m.group(1)), float(m.group(2)) except ValueError: return None if 44.5 <= lat <= 63.0 and -80.0 <= lng <= -56.0: return lat, lng return None def flatten(html: str) -> str: """HTML -> texte « valeur | libellé » pour extraire les tableaux Centris.""" t = _html.unescape(re.sub(r"<[^>]+>", " | ", html)) t = re.sub(r"[ \t\r\n]*\|[ \t\r\n|]*", " | ", t) return re.sub(r"[ \t]+", " ", t) # libellés Centris standard cherchés dans « valeur | libellé » OU « libellé | valeur » _LABELS = [ "Type de propriété", "Genre de propriété", "Style de bâtiment", "Année de construction", "Superficie habitable", "Superficie du terrain", "Superficie du bâtiment (au sol)", "Nombre de pièces", "Nombre d'unités", "Stationnement (total)", "Stationnement", "Garage", "Système de chauffage", "Énergie pour le chauffage", "Type de fenestration", "Fenêtres", "Toiture", "Revêtement", "Sous-sol", "Piscine", "Zonage", "Système d'égouts", "Approvisionnement en eau", "Déménagement", "Taxes municipales", "Taxes scolaires", "Évaluation municipale (terrain)", "Évaluation municipale (bâtiment)", "Cuisine", ] def centris_details(text: str) -> dict: """Extrait les caractéristiques Centris d'un texte aplati (les deux ordres).""" out: dict = {} for label in _LABELS: lab = re.escape(label) m = (re.search(r"([^|]{1,55})\s*\|\s*" + lab + r"\b", text) or re.search(lab + r"\b\s*\|\s*([^|]{1,55})", text)) if m: val = m.group(1).strip(" |") if val and 1 <= len(val) <= 55 and val.lower() != label.lower(): out[label] = val return out _INT_RE = re.compile(r"\d+") def _int(v): if v is None: return None if isinstance(v, (int, float)): return int(v) or None m = _INT_RE.search(str(v)) return int(m.group()) if m else None def enrich(connector, listings, limit, parse_fn, key="v1", fetch_html=None): """Enrichit `listings` via leur page détail, avec cache BD + plafond `limit`. - `parse_fn(html) -> dict` : extrait les champs riches d'une page détail. - `key` : versionne le cache (changer pour forcer un rafraîchissement). - `fetch_html(url) -> str` : par défaut connector.get(url).text ; passer connector.get_rendered pour les sites derrière Firecrawl/anti-bot. """ if limit <= 0: return from .. import db fetch_html = fetch_html or (lambda u: connector.get(u).text) con = db.connect() budget = limit try: for lst in listings: cached = db.get_cached_detail(con, connector.source_id, lst.external_id, key) if cached is None: if budget <= 0: continue try: cached = parse_fn(fetch_html(lst.url)) except Exception: cached = {} db.put_cached_detail(con, connector.source_id, lst.external_id, key, cached) budget -= 1 apply_detail(lst, cached) finally: con.close() def apply_detail(lst: PropertyListing, d: dict) -> None: """Applique un payload détail au PropertyListing sans écraser les valeurs déjà présentes (sauf images : on garde la plus grande galerie).""" if not d: return imgs = d.get("images") if imgs and len(imgs) > len(lst.images): lst.images = imgs if d.get("features"): # fusionne en dédoublonnant seen = {f.lower() for f in lst.features} for f in d["features"]: if f.lower() not in seen: lst.features.append(f) seen.add(f.lower()) if d.get("details"): lst.details.update(d["details"]) if d.get("broker_name"): lst.broker_name = d["broker_name"] for f in ("description", "price_label"): if d.get(f) and not getattr(lst, f, ""): setattr(lst, f, d[f]) for f in ("bedrooms", "bathrooms", "powder_rooms", "year_built", "area_sqft", "lot_sqft", "lat", "lng", "broker_phone", "price"): if d.get(f) is not None and getattr(lst, f, None) in (None, "", 0): setattr(lst, f, d[f])