# ----------------------------------------------------------------------------- # Rent-Ka — Rental listings aggregator (Canada, outside Québec) # Author: 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 # plateformes de visite virtuelle reconnues dans le HTML d'une fiche _VTOUR_RE = re.compile( r'https?://(?:my\.)?(?:matterport\.com/show/[^"\'\s<>]+' r'|(?:www\.)?youriguide\.com/[^"\'\s<>]+' r'|(?:www\.)?klapty\.com/tour/[^"\'\s<>]+' r'|kuula\.co/(?:share|post)/[^"\'\s<>]+' r'|tours?\.[a-z0-9-]+\.(?:com|ca)/[^"\'\s<>]*(?:tour|visite)[^"\'\s<>]*' r'|(?:www\.)?realvision\.com/[^"\'\s<>]+)', re.I) def virtual_tour(html: str) -> str | None: """Première URL de visite virtuelle (Matterport, iGUIDE, Klapty, Kuula…) trouvée dans le HTML d'une fiche. None si absente — jamais inventée.""" m = _VTOUR_RE.search(html or "") if not m: return None return _html.unescape(m.group(0)).replace("\\/", "/").rstrip("\\").rstrip('&') 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() class TtlDetailCache: """Cache BD des pages détail AVEC durée de vie, pour les portails où la fiche est la seule source de données (LogisQuébec, DuProprio…). Contrairement à enrich() (cache permanent tant que la clé ne change pas), chaque fiche est re-visitée après `ttl_days` pour capter les changements de prix/disponibilité — dans la limite de `budget` requêtes par synchronisation. Budget épuisé : le payload périmé est réutilisé tel quel (jamais de trou de données) ; une fiche jamais visitée retourne None et sera captée à une synchronisation suivante. """ def __init__(self, connector, budget: int, ttl_days: float = 7.0, key: str = "v1", fetch_html=None) -> None: from .. import db self.connector = connector self.con = db.connect() self.budget = budget self.ttl = ttl_days * 86400 self.key = key self.fetch_html = fetch_html or (lambda u: connector.get(u).text) def peek(self, external_id: str) -> tuple[dict | None, bool]: """(payload en cache, frais ?) SANS déclencher de requête ni toucher au budget — permet aux connecteurs de planifier des lots parallèles.""" import time as _time row = self.con.execute( "SELECT key, payload, fetched_at FROM detail_cache" " WHERE source=? AND external_id=?", (self.connector.source_id, str(external_id))).fetchone() stale = None if row and row["payload"]: try: stale = json.loads(row["payload"]) except ValueError: stale = None if (stale is not None and row["key"] == self.key and _time.time() - (row["fetched_at"] or 0) < self.ttl): return stale, True # frais : aucun trafic requis return stale, False def put(self, external_id: str, payload: dict) -> None: """Écrit un payload obtenu hors de get() (ex. téléchargement en lot).""" from .. import db db.put_cached_detail(self.con, self.connector.source_id, str(external_id), self.key, payload) def get(self, external_id: str, url: str, parse_fn) -> dict | None: from .. import db stale, fresh = self.peek(external_id) if fresh: return stale # frais : aucun trafic if self.budget <= 0: return stale # périmé toléré / None si jamais vu self.budget -= 1 try: payload = parse_fn(self.fetch_html(url)) or {} except Exception as exc: # 404/410 = fiche retirée code = getattr(getattr(exc, "response", None), "status_code", None) if code in (404, 410): payload = {"gone": True} else: return stale # erreur réseau : on garde l'ancien db.put_cached_detail(self.con, self.connector.source_id, str(external_id), self.key, payload) return payload def close(self) -> None: try: self.con.close() except Exception: pass 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]) from ..normalize import coerce_count for f in ("bedrooms", "bathrooms"): if d.get(f) is not None and getattr(lst, f, None) is None: setattr(lst, f, coerce_count(d[f])) if d.get("virtual_tour") and not lst.details.get("virtual_tour"): lst.details["virtual_tour"] = d["virtual_tour"]