# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (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 les connecteurs de portails répètent pour capter TOUTES les # infos de la fiche source : description JSON-LD, coordonnées, application # au Listing avec cache BD et budget de requêtes par synchronisation. # ----------------------------------------------------------------------------- from __future__ import annotations import html as _html import json import re from ..schema import Listing _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 de fiches.""" 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 (fiches de courtiers) cherchés dans un texte aplati # « valeur | libellé » OU « libellé | valeur » — version location _LABELS = [ "Type de propriété", "Genre de propriété", "Style de bâtiment", "Année de construction", "Superficie habitable", "Superficie du terrain", "Nombre de pièces", "Nombre d'unités", "Stationnement (total)", "Stationnement", "Garage", "Système de chauffage", "Énergie pour le chauffage", "Piscine", "Déménagement", "Date d'emménagement", "Disponibilité", "Bail", "Durée du bail", "Meublé", "Animaux", "Inclus dans le loyer", "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 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 / get_scrapfly pour les sites derrière anti-bot. Le budget `limit` fait que chaque sync n'enrichit qu'un quota de fiches ; le parc se complète sur plusieurs cycles (le cache est permanent tant que la clé ne change pas). """ 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: Listing, d: dict) -> None: """Applique un payload détail au Listing 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("amenities"): seen = {a.lower() for a in lst.amenities} for a in d["amenities"]: if a.lower() not in seen: lst.amenities.append(a) seen.add(a.lower()) if d.get("details"): merged = dict(d["details"]) merged.update(lst.details) # les valeurs du connecteur priment lst.details = merged # 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", "unit_type", "availability", "title"): if d.get(f) and not getattr(lst, f, ""): setattr(lst, f, d[f]) for f in ("price", "area_sqft", "lat", "lng", "availability_date", "pets", "furnished"): if d.get(f) is not None and getattr(lst, f, None) is None: setattr(lst, f, d[f])