# ----------------------------------------------------------------------------- # 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: # strict=False : tolère les \n/\t bruts dans les chaînes (Yoamo…) data = json.loads(block, strict=False) 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", ] # en-têtes de section des fiches (jamais des valeurs valides) _SECTION_HEADERS = { "bâtiment et intérieur", "particularités du bâtiment", "particularités du terrain", "particularités du site", "caractéristiques", "caractéristiques de la propriété", "détails financiers", "taxes et coûts", "taxes\xa0et\xa0coûts", "détails des pièces", "détails des rénovations", "inclusions et exclusions", "dimensions", "addenda", "frais mensuels", "évaluations", "équipement disponible", "dans les environs", "niveau", "revêtement", "détails", "taxes", "total", "pièce", "couvre-sol", } _LABELS_LOWER = {lb.lower() for lb in _LABELS} def _plausible(label: str, val: str) -> bool: """Garde-fou : rejette les valeurs qui sont d'autres libellés/en-têtes de section (l'aplatissement « | » rend les deux ordres ambigus) et exige un format minimal pour les champs monétaires/numériques.""" low = val.lower().strip(" :") if not val or len(val) > 55 or low == label.lower(): return False if low in _LABELS_LOWER or low in _SECTION_HEADERS: return False if label.startswith(("Taxes", "Évaluation")) and "$" not in val: return False if label == "Année de construction" and not re.search(r"\b(1[6-9]|20)\d{2}\b", val): return False if label.startswith("Superficie") and not re.search(r"\d", val): return False return True def centris_details(text: str) -> dict: """Extrait les caractéristiques Centris d'un texte aplati. Les fiches Centris modernes rendent « Libellé | Valeur » (le libellé précède la valeur) — c'est l'ordre essayé en premier ; l'ancien ordre « Valeur | Libellé » reste en repli. Un garde-fou (_plausible) évite de capter l'en-tête de section ou le libellé voisin comme valeur.""" out: dict = {} for label in _LABELS: lab = re.escape(label) for m in (re.search(lab + r"\s*(?:\(\d{4}\))?\s*\|\s*([^|]{1,55})", text), re.search(r"([^|]{1,55})\s*\|\s*" + lab + r"\b", text)): if m: val = re.sub(r"\s+", " ", m.group(1)).strip(" |") if _plausible(label, val): out[label] = val break 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: stale = db.get_stale_detail(con, connector.source_id, lst.external_id) if budget <= 0: # budget épuisé : payload périmé (ancienne clé) plutôt que # rien — la fiche garde ses photos/détails en attendant # son re-parse à un prochain cycle if stale: apply_detail(lst, stale) continue try: cached = parse_fn(fetch_html(lst.url)) except Exception: cached = {} if not cached and stale: # échec transitoire (challenge, timeout) : on garde l'ancien # payload et on ne l'écrase pas — nouvel essai au prochain cycle apply_detail(lst, stale) budget -= 1 continue 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"] # description : on garde la plus riche (la fiche détail bat le résumé liste) if d.get("description") and len(d["description"]) > len(lst.description or ""): lst.description = d["description"] for f in ("price_label", "address", "city", "sector", "property_type"): 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])