Python 68.8%
TypeScript 18.6%
CSS 8.7%
JavaScript 3.3%
HTML 0.6%
1# -----------------------------------------------------------------------------2# Rent-Ka — Rental listings aggregator (Canada, outside Québec)3# Author: Simon-Pierre Boucher — contact@spboucher.ai4# connectors/_detailutil.py : utilitaires partagés d'enrichissement « page détail »5# Mutualise ce que les connecteurs de portails répètent pour capter TOUTES les6# infos de la fiche source : description JSON-LD, coordonnées, application7# au Listing avec cache BD et budget de requêtes par synchronisation.8# -----------------------------------------------------------------------------9from __future__ import annotations1011import html as _html12import json13import re1415from ..schema import Listing1617_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 best484950# plateformes de visite virtuelle reconnues dans le HTML d'une fiche51_VTOUR_RE = re.compile(52 r'https?://(?:my\.)?(?:matterport\.com/show/[^"\'\s<>]+'53 r'|(?:www\.)?youriguide\.com/[^"\'\s<>]+'54 r'|(?:www\.)?klapty\.com/tour/[^"\'\s<>]+'55 r'|kuula\.co/(?:share|post)/[^"\'\s<>]+'56 r'|tours?\.[a-z0-9-]+\.(?:com|ca)/[^"\'\s<>]*(?:tour|visite)[^"\'\s<>]*'57 r'|(?:www\.)?realvision\.com/[^"\'\s<>]+)', re.I)585960def virtual_tour(html: str) -> str | None:61 """Première URL de visite virtuelle (Matterport, iGUIDE, Klapty, Kuula…)62 trouvée dans le HTML d'une fiche. None si absente — jamais inventée."""63 m = _VTOUR_RE.search(html or "")64 if not m:65 return None66 return _html.unescape(m.group(0)).replace("\\/", "/").rstrip("\\").rstrip('&')676869def gmaps_coords(html: str) -> tuple[float, float] | None:70 m = _COORD_RE.search(html)71 if not m:72 return None73 try:74 lat, lng = float(m.group(1)), float(m.group(2))75 except ValueError:76 return None77 if 44.5 <= lat <= 63.0 and -80.0 <= lng <= -56.0:78 return lat, lng79 return None808182def flatten(html: str) -> str:83 """HTML -> texte « valeur | libellé » pour extraire les tableaux de fiches."""84 t = _html.unescape(re.sub(r"<[^>]+>", " | ", html))85 t = re.sub(r"[ \t\r\n]*\|[ \t\r\n|]*", " | ", t)86 return re.sub(r"[ \t]+", " ", t)878889# libellés Centris standard (fiches de courtiers) cherchés dans un texte aplati90# « valeur | libellé » OU « libellé | valeur » — version location91_LABELS = [92 "Type de propriété", "Genre de propriété", "Style de bâtiment",93 "Année de construction", "Superficie habitable", "Superficie du terrain",94 "Nombre de pièces", "Nombre d'unités", "Stationnement (total)",95 "Stationnement", "Garage", "Système de chauffage",96 "Énergie pour le chauffage", "Piscine", "Déménagement", "Date d'emménagement",97 "Disponibilité", "Bail", "Durée du bail", "Meublé", "Animaux",98 "Inclus dans le loyer", "Cuisine",99]100101102def centris_details(text: str) -> dict:103 """Extrait les caractéristiques Centris d'un texte aplati (les deux ordres)."""104 out: dict = {}105 for label in _LABELS:106 lab = re.escape(label)107 m = (re.search(r"([^|]{1,55})\s*\|\s*" + lab + r"\b", text)108 or re.search(lab + r"\b\s*\|\s*([^|]{1,55})", text))109 if m:110 val = m.group(1).strip(" |")111 if val and 1 <= len(val) <= 55 and val.lower() != label.lower():112 out[label] = val113 return out114115116def enrich(connector, listings, limit, parse_fn, key="v1", fetch_html=None):117 """Enrichit `listings` via leur page détail, avec cache BD + plafond `limit`.118119 - `parse_fn(html) -> dict` : extrait les champs riches d'une page détail.120 - `key` : versionne le cache (changer pour forcer un rafraîchissement).121 - `fetch_html(url) -> str` : par défaut connector.get(url).text ; passer122 connector.get_rendered / get_scrapfly pour les sites derrière anti-bot.123 Le budget `limit` fait que chaque sync n'enrichit qu'un quota de fiches ;124 le parc se complète sur plusieurs cycles (le cache est permanent tant que125 la clé ne change pas).126 """127 if limit <= 0:128 return129 from .. import db130 fetch_html = fetch_html or (lambda u: connector.get(u).text)131 con = db.connect()132 budget = limit133 try:134 for lst in listings:135 cached = db.get_cached_detail(con, connector.source_id, lst.external_id, key)136 if cached is None:137 if budget <= 0:138 continue139 try:140 cached = parse_fn(fetch_html(lst.url))141 except Exception:142 cached = {}143 db.put_cached_detail(con, connector.source_id, lst.external_id, key, cached)144 budget -= 1145 apply_detail(lst, cached)146 finally:147 con.close()148149150class TtlDetailCache:151 """Cache BD des pages détail AVEC durée de vie, pour les portails où la152 fiche est la seule source de données (LogisQuébec, DuProprio…).153154 Contrairement à enrich() (cache permanent tant que la clé ne change pas),155 chaque fiche est re-visitée après `ttl_days` pour capter les changements156 de prix/disponibilité — dans la limite de `budget` requêtes par157 synchronisation. Budget épuisé : le payload périmé est réutilisé tel quel158 (jamais de trou de données) ; une fiche jamais visitée retourne None et159 sera captée à une synchronisation suivante.160 """161162 def __init__(self, connector, budget: int, ttl_days: float = 7.0,163 key: str = "v1", fetch_html=None) -> None:164 from .. import db165 self.connector = connector166 self.con = db.connect()167 self.budget = budget168 self.ttl = ttl_days * 86400169 self.key = key170 self.fetch_html = fetch_html or (lambda u: connector.get(u).text)171172 def peek(self, external_id: str) -> tuple[dict | None, bool]:173 """(payload en cache, frais ?) SANS déclencher de requête ni toucher174 au budget — permet aux connecteurs de planifier des lots parallèles."""175 import time as _time176 row = self.con.execute(177 "SELECT key, payload, fetched_at FROM detail_cache"178 " WHERE source=? AND external_id=?",179 (self.connector.source_id, str(external_id))).fetchone()180 stale = None181 if row and row["payload"]:182 try:183 stale = json.loads(row["payload"])184 except ValueError:185 stale = None186 if (stale is not None and row["key"] == self.key187 and _time.time() - (row["fetched_at"] or 0) < self.ttl):188 return stale, True # frais : aucun trafic requis189 return stale, False190191 def put(self, external_id: str, payload: dict) -> None:192 """Écrit un payload obtenu hors de get() (ex. téléchargement en lot)."""193 from .. import db194 db.put_cached_detail(self.con, self.connector.source_id,195 str(external_id), self.key, payload)196197 def get(self, external_id: str, url: str, parse_fn) -> dict | None:198 from .. import db199 stale, fresh = self.peek(external_id)200 if fresh:201 return stale # frais : aucun trafic202 if self.budget <= 0:203 return stale # périmé toléré / None si jamais vu204 self.budget -= 1205 try:206 payload = parse_fn(self.fetch_html(url)) or {}207 except Exception as exc: # 404/410 = fiche retirée208 code = getattr(getattr(exc, "response", None), "status_code", None)209 if code in (404, 410):210 payload = {"gone": True}211 else:212 return stale # erreur réseau : on garde l'ancien213 db.put_cached_detail(self.con, self.connector.source_id,214 str(external_id), self.key, payload)215 return payload216217 def close(self) -> None:218 try:219 self.con.close()220 except Exception:221 pass222223224def apply_detail(lst: Listing, d: dict) -> None:225 """Applique un payload détail au Listing sans écraser les valeurs déjà226 présentes (sauf images : on garde la plus grande galerie)."""227 if not d:228 return229 imgs = d.get("images")230 if imgs and len(imgs) > len(lst.images):231 lst.images = imgs232 if d.get("amenities"):233 seen = {a.lower() for a in lst.amenities}234 for a in d["amenities"]:235 if a.lower() not in seen:236 lst.amenities.append(a)237 seen.add(a.lower())238 if d.get("details"):239 merged = dict(d["details"])240 merged.update(lst.details) # les valeurs du connecteur priment241 lst.details = merged242 # description : on garde la plus riche (la fiche détail bat le résumé liste)243 if d.get("description") and len(d["description"]) > len(lst.description or ""):244 lst.description = d["description"]245 for f in ("price_label", "address", "city", "sector", "unit_type",246 "availability", "title"):247 if d.get(f) and not getattr(lst, f, ""):248 setattr(lst, f, d[f])249 for f in ("price", "area_sqft", "lat", "lng", "availability_date",250 "pets", "furnished"):251 if d.get(f) is not None and getattr(lst, f, None) is None:252 setattr(lst, f, d[f])253 from ..normalize import coerce_count254 for f in ("bedrooms", "bathrooms"):255 if d.get(f) is not None and getattr(lst, f, None) is None:256 setattr(lst, f, coerce_count(d[f]))257 if d.get("virtual_tour") and not lst.details.get("virtual_tour"):258 lst.details["virtual_tour"] = d["virtual_tour"]259