Python 67%
TypeScript 18.2%
CSS 14.4%
1# -----------------------------------------------------------------------------2# Immo-Ka — Agrégateur de maisons à vendre (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# connectors/_detailutil.py : utilitaires partagés d'enrichissement « page détail »5# Mutualise ce que chaque connecteur d'agence répète pour capter TOUTES les6# infos de la fiche source (comme remax_quebec.py) : description JSON-LD,7# coordonnées, aplatissement HTML, application au PropertyListing.8# -----------------------------------------------------------------------------9from __future__ import annotations1011import html as _html12import json13import re1415from ..schema import PropertyListing1617_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 # strict=False : tolère les \n/\t bruts dans les chaînes (Yoamo…)29 data = json.loads(block, strict=False)30 except ValueError:31 continue32 graph = data.get("@graph", [data]) if isinstance(data, dict) else data33 for node in (graph if isinstance(graph, list) else [graph]):34 if isinstance(node, dict):35 yield node363738def ld_description(html: str) -> str:39 """Description depuis un nœud JSON-LD de type propriété (le plus long trouvé)."""40 best = ""41 for n in ld_nodes(html):42 t = n.get("@type")43 types = t if isinstance(t, list) else [t]44 if any(x in _LD_PROP_TYPES for x in types) and n.get("description"):45 d = _html.unescape(str(n["description"])).strip()46 if len(d) > len(best):47 best = d48 return best495051def gmaps_coords(html: str) -> tuple[float, float] | None:52 m = _COORD_RE.search(html)53 if not m:54 return None55 try:56 lat, lng = float(m.group(1)), float(m.group(2))57 except ValueError:58 return None59 if 44.5 <= lat <= 63.0 and -80.0 <= lng <= -56.0:60 return lat, lng61 return None626364def flatten(html: str) -> str:65 """HTML -> texte « valeur | libellé » pour extraire les tableaux Centris."""66 t = _html.unescape(re.sub(r"<[^>]+>", " | ", html))67 t = re.sub(r"[ \t\r\n]*\|[ \t\r\n|]*", " | ", t)68 return re.sub(r"[ \t]+", " ", t)697071# libellés Centris standard cherchés dans « valeur | libellé » OU « libellé | valeur »72_LABELS = [73 "Type de propriété", "Genre de propriété", "Style de bâtiment",74 "Année de construction", "Superficie habitable", "Superficie du terrain",75 "Superficie du bâtiment (au sol)", "Nombre de pièces", "Nombre d'unités",76 "Stationnement (total)", "Stationnement", "Garage", "Système de chauffage",77 "Énergie pour le chauffage", "Type de fenestration", "Fenêtres", "Toiture",78 "Revêtement", "Sous-sol", "Piscine", "Zonage", "Système d'égouts",79 "Approvisionnement en eau", "Déménagement", "Taxes municipales",80 "Taxes scolaires", "Évaluation municipale (terrain)",81 "Évaluation municipale (bâtiment)", "Cuisine",82]838485# en-têtes de section des fiches (jamais des valeurs valides)86_SECTION_HEADERS = {87 "bâtiment et intérieur", "particularités du bâtiment",88 "particularités du terrain", "particularités du site", "caractéristiques",89 "caractéristiques de la propriété", "détails financiers", "taxes et coûts",90 "taxes\xa0et\xa0coûts", "détails des pièces", "détails des rénovations",91 "inclusions et exclusions", "dimensions", "addenda", "frais mensuels",92 "évaluations", "équipement disponible", "dans les environs",93 "niveau", "revêtement", "détails", "taxes", "total", "pièce", "couvre-sol",94}95_LABELS_LOWER = {lb.lower() for lb in _LABELS}969798def _plausible(label: str, val: str) -> bool:99 """Garde-fou : rejette les valeurs qui sont d'autres libellés/en-têtes de100 section (l'aplatissement « | » rend les deux ordres ambigus) et exige un101 format minimal pour les champs monétaires/numériques."""102 low = val.lower().strip(" :")103 if not val or len(val) > 55 or low == label.lower():104 return False105 if low in _LABELS_LOWER or low in _SECTION_HEADERS:106 return False107 if label.startswith(("Taxes", "Évaluation")) and "$" not in val:108 return False109 if label == "Année de construction" and not re.search(r"\b(1[6-9]|20)\d{2}\b", val):110 return False111 if label.startswith("Superficie") and not re.search(r"\d", val):112 return False113 return True114115116def centris_details(text: str) -> dict:117 """Extrait les caractéristiques Centris d'un texte aplati.118119 Les fiches Centris modernes rendent « Libellé | Valeur » (le libellé120 précède la valeur) — c'est l'ordre essayé en premier ; l'ancien ordre121 « Valeur | Libellé » reste en repli. Un garde-fou (_plausible) évite de122 capter l'en-tête de section ou le libellé voisin comme valeur."""123 out: dict = {}124 for label in _LABELS:125 lab = re.escape(label)126 for m in (re.search(lab + r"\s*(?:\(\d{4}\))?\s*\|\s*([^|]{1,55})", text),127 re.search(r"([^|]{1,55})\s*\|\s*" + lab + r"\b", text)):128 if m:129 val = re.sub(r"\s+", " ", m.group(1)).strip(" |")130 if _plausible(label, val):131 out[label] = val132 break133 return out134135136_INT_RE = re.compile(r"\d+")137138139def _int(v):140 if v is None:141 return None142 if isinstance(v, (int, float)):143 return int(v) or None144 m = _INT_RE.search(str(v))145 return int(m.group()) if m else None146147148def enrich(connector, listings, limit, parse_fn, key="v1", fetch_html=None):149 """Enrichit `listings` via leur page détail, avec cache BD + plafond `limit`.150151 - `parse_fn(html) -> dict` : extrait les champs riches d'une page détail.152 - `key` : versionne le cache (changer pour forcer un rafraîchissement).153 - `fetch_html(url) -> str` : par défaut connector.get(url).text ; passer154 connector.get_rendered pour les sites derrière Firecrawl/anti-bot.155 """156 if limit <= 0:157 return158 from .. import db159 fetch_html = fetch_html or (lambda u: connector.get(u).text)160 con = db.connect()161 budget = limit162 try:163 for lst in listings:164 cached = db.get_cached_detail(con, connector.source_id, lst.external_id, key)165 if cached is None:166 stale = db.get_stale_detail(con, connector.source_id, lst.external_id)167 if budget <= 0:168 # budget épuisé : payload périmé (ancienne clé) plutôt que169 # rien — la fiche garde ses photos/détails en attendant170 # son re-parse à un prochain cycle171 if stale:172 apply_detail(lst, stale)173 continue174 try:175 cached = parse_fn(fetch_html(lst.url))176 except Exception:177 cached = {}178 if not cached and stale:179 # échec transitoire (challenge, timeout) : on garde l'ancien180 # payload et on ne l'écrase pas — nouvel essai au prochain cycle181 apply_detail(lst, stale)182 budget -= 1183 continue184 db.put_cached_detail(con, connector.source_id, lst.external_id, key, cached)185 budget -= 1186 apply_detail(lst, cached)187 finally:188 con.close()189190191def apply_detail(lst: PropertyListing, d: dict) -> None:192 """Applique un payload détail au PropertyListing sans écraser les valeurs déjà193 présentes (sauf images : on garde la plus grande galerie)."""194 if not d:195 return196 imgs = d.get("images")197 if imgs and len(imgs) > len(lst.images):198 lst.images = imgs199 if d.get("features"):200 # fusionne en dédoublonnant201 seen = {f.lower() for f in lst.features}202 for f in d["features"]:203 if f.lower() not in seen:204 lst.features.append(f)205 seen.add(f.lower())206 if d.get("details"):207 lst.details.update(d["details"])208 if d.get("broker_name"):209 lst.broker_name = d["broker_name"]210 # description : on garde la plus riche (la fiche détail bat le résumé liste)211 if d.get("description") and len(d["description"]) > len(lst.description or ""):212 lst.description = d["description"]213 for f in ("price_label", "address", "city", "sector", "property_type"):214 if d.get(f) and not getattr(lst, f, ""):215 setattr(lst, f, d[f])216 for f in ("bedrooms", "bathrooms", "powder_rooms", "year_built",217 "area_sqft", "lot_sqft", "lat", "lng", "broker_phone", "price"):218 if d.get(f) is not None and getattr(lst, f, None) in (None, "", 0):219 setattr(lst, f, d[f])220