# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/gestion_fauvel.py : connecteur Gestion Fauvel (gestionfauvel.com) # WordPress + Elementor + JetEngine. La page /logements-a-louer/ expose une # grille .jet-listing-grid__item : data-post-id (id stable), data-url, # en-têtes (disponibilité, prix, titre) et terme JetEngine = ville réelle. # Fiches détail (cache BD) : adresse civique, description complète (unités # dispo + inclusions) et galerie photos (carrousel Elementor). # ----------------------------------------------------------------------------- from __future__ import annotations import hashlib import re from bs4 import BeautifulSoup from ..schema import Listing, normalize_unit_type, parse_price from .base import BaseConnector BASE = "https://gestionfauvel.com" LIST_URL = f"{BASE}/logements-a-louer/" # adresse civique : « 555, rue des Écoles, app. 105 Drummondville » _ADDR_RE = re.compile( r"^\d+[\s,]+.*\b(rue|boul(?:evard|\.)?|avenue|av\.|chemin|carr[ée]|place|" r"c[ôo]te|mont[ée]e|rang)\b", re.I) _IMG_EXT_RE = re.compile(r"\.(?:jpe?g|png|webp)$", re.I) _UNIT_RE = re.compile(r"^(?:\d½\+?|Studio|Loft|Maison)$") class GestionFauvelConnector(BaseConnector): source_id = "gestion_fauvel" request_delay = 0.6 max_details = 25 # garde-fou fiches détail (vraies requêtes) def fetch(self) -> list[Listing]: listings: dict[str, Listing] = {} soup = BeautifulSoup(self.get(LIST_URL).text, "html.parser") for card in soup.select(".jet-listing-grid__item"): try: self._parse_card(card, listings) except Exception: continue # fiches détail (cache BD) : adresse, description, photos self._fetched = 0 for lst in listings.values(): card_key = hashlib.sha1( f"{lst.title}|{lst.price_label}|{lst.availability}" .encode("utf-8")).hexdigest() try: payload = self.detail(lst.external_id, card_key, lambda u=lst.url: self._fetch_detail(u)) except Exception: continue self._apply_detail(lst, payload) return list(listings.values()) # -- carte JetEngine ------------------------------------------------------ def _parse_card(self, card, listings: dict[str, Listing]) -> None: ext_id = card.get("data-post-id", "") overlay = card.select_one(".jet-engine-listing-overlay-wrap[data-url]") url = overlay.get("data-url") if overlay else "" if not ext_id or not url or ext_id in listings: return heads = [h.get_text(" ", strip=True) for h in card.select(".elementor-heading-title") if h.get_text(strip=True)] if not heads: return title = re.sub(r"\s+", " ", heads[-1]) # le titre ferme la carte price_label = next((h for h in heads[:-1] if "$" in h), "") avail_parts = [h for h in heads[:-1] if h != price_label] availability = " ".join(avail_parts).strip() # exclusions : immeubles complets, volet commercial if re.search(r"complet", availability + " " + title, re.I): return if re.search(r"commercial|bureau|local|entrep[ôo]t|stationnement", title, re.I): return terms = card.select_one(".jet-listing-dynamic-terms") city = terms.get_text(" ", strip=True) if terms else "" unit_type = normalize_unit_type(title) if not _UNIT_RE.fullmatch(unit_type or ""): unit_type = "" # titre sans format d'unité (ex. « Condos locatifs ») images = [] img = card.select_one("img[src]") if img and img["src"].startswith("http"): images = [img["src"]] listings[str(ext_id)] = Listing( source=self.source_id, external_id=str(ext_id), url=url, title=title, city=city, unit_type=unit_type, price=parse_price(price_label), price_label=price_label, availability=availability, images=images, ) # -- fiche détail (Elementor) ---------------------------------------------- def _fetch_detail(self, url: str) -> dict: """Adresse civique (en-tête h3), description (bloc après le h2 « Description ») et galerie photos (liens pleine taille du carrousel).""" if self._fetched >= self.max_details: raise RuntimeError("budget de fiches détail atteint") self._fetched += 1 soup = BeautifulSoup(self.get(url).text, "html.parser") out: dict = {} for h3 in soup.select("h3.elementor-heading-title"): txt = h3.get_text(" ", strip=True) if _ADDR_RE.match(txt): out["address"] = txt break desc_h2 = next((h for h in soup.select("h2") if h.get_text(strip=True).lower() == "description"), None) if desc_h2: parts: list[str] = [] for el in desc_h2.find_all_next(["p", "li", "h2"]): if el.name == "h2": break t = re.sub(r"\s+", " ", el.get_text(" ", strip=True)) if t and t not in parts: parts.append(t) if parts: out["description"] = "\n".join(parts)[:2000] images: list[str] = [] for a in soup.select(".elementor-widget-image-carousel a[href]"): u = a["href"] if u.startswith("http") and _IMG_EXT_RE.search(u) and u not in images: images.append(u) out["images"] = images[:30] return out def _apply_detail(self, lst: Listing, d: dict) -> None: """Reporte le payload (frais/cache) sur l'annonce.""" if not d: return if d.get("address"): lst.address = d["address"] if d.get("description"): lst.description = d["description"] if d.get("images"): lst.images = list(dict.fromkeys(d["images"] + lst.images))[:30]