spb/lou-ka Public
Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 99.7%
1# -----------------------------------------------------------------------------2# Lou-Ka — Agrégateur de logements à louer (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# connectors/gestion_fauvel.py : connecteur Gestion Fauvel (gestionfauvel.com)5# WordPress + Elementor + JetEngine. La page /logements-a-louer/ expose une6# grille .jet-listing-grid__item : data-post-id (id stable), data-url,7# en-têtes (disponibilité, prix, titre) et terme JetEngine = ville réelle.8# Fiches détail (cache BD) : adresse civique, description complète (unités9# dispo + inclusions) et galerie photos (carrousel Elementor).10# -----------------------------------------------------------------------------11from __future__ import annotations1213import hashlib14import re1516from bs4 import BeautifulSoup1718from ..schema import Listing, normalize_unit_type, parse_price19from .base import BaseConnector2021BASE = "https://gestionfauvel.com"22LIST_URL = f"{BASE}/logements-a-louer/"2324# adresse civique : « 555, rue des Écoles, app. 105 Drummondville »25_ADDR_RE = re.compile(26 r"^\d+[\s,]+.*\b(rue|boul(?:evard|\.)?|avenue|av\.|chemin|carr[ée]|place|"27 r"c[ôo]te|mont[ée]e|rang)\b", re.I)28_IMG_EXT_RE = re.compile(r"\.(?:jpe?g|png|webp)$", re.I)29_UNIT_RE = re.compile(r"^(?:\d½\+?|Studio|Loft|Maison)$")303132class GestionFauvelConnector(BaseConnector):33 source_id = "gestion_fauvel"34 request_delay = 0.635 max_details = 25 # garde-fou fiches détail (vraies requêtes)3637 def fetch(self) -> list[Listing]:38 listings: dict[str, Listing] = {}39 soup = BeautifulSoup(self.get(LIST_URL).text, "html.parser")40 for card in soup.select(".jet-listing-grid__item"):41 try:42 self._parse_card(card, listings)43 except Exception:44 continue4546 # fiches détail (cache BD) : adresse, description, photos47 self._fetched = 048 for lst in listings.values():49 card_key = hashlib.sha1(50 f"{lst.title}|{lst.price_label}|{lst.availability}"51 .encode("utf-8")).hexdigest()52 try:53 payload = self.detail(lst.external_id, card_key,54 lambda u=lst.url: self._fetch_detail(u))55 except Exception:56 continue57 self._apply_detail(lst, payload)58 return list(listings.values())5960 # -- carte JetEngine ------------------------------------------------------61 def _parse_card(self, card, listings: dict[str, Listing]) -> None:62 ext_id = card.get("data-post-id", "")63 overlay = card.select_one(".jet-engine-listing-overlay-wrap[data-url]")64 url = overlay.get("data-url") if overlay else ""65 if not ext_id or not url or ext_id in listings:66 return6768 heads = [h.get_text(" ", strip=True)69 for h in card.select(".elementor-heading-title")70 if h.get_text(strip=True)]71 if not heads:72 return73 title = re.sub(r"\s+", " ", heads[-1]) # le titre ferme la carte74 price_label = next((h for h in heads[:-1] if "$" in h), "")75 avail_parts = [h for h in heads[:-1] if h != price_label]76 availability = " ".join(avail_parts).strip()7778 # exclusions : immeubles complets, volet commercial79 if re.search(r"complet", availability + " " + title, re.I):80 return81 if re.search(r"commercial|bureau|local|entrep[ôo]t|stationnement",82 title, re.I):83 return8485 terms = card.select_one(".jet-listing-dynamic-terms")86 city = terms.get_text(" ", strip=True) if terms else ""8788 unit_type = normalize_unit_type(title)89 if not _UNIT_RE.fullmatch(unit_type or ""):90 unit_type = "" # titre sans format d'unité (ex. « Condos locatifs »)9192 images = []93 img = card.select_one("img[src]")94 if img and img["src"].startswith("http"):95 images = [img["src"]]9697 listings[str(ext_id)] = Listing(98 source=self.source_id,99 external_id=str(ext_id),100 url=url,101 title=title,102 city=city,103 unit_type=unit_type,104 price=parse_price(price_label),105 price_label=price_label,106 availability=availability,107 images=images,108 )109110 # -- fiche détail (Elementor) ----------------------------------------------111 def _fetch_detail(self, url: str) -> dict:112 """Adresse civique (en-tête h3), description (bloc après le h2113 « Description ») et galerie photos (liens pleine taille du carrousel)."""114 if self._fetched >= self.max_details:115 raise RuntimeError("budget de fiches détail atteint")116 self._fetched += 1117 soup = BeautifulSoup(self.get(url).text, "html.parser")118 out: dict = {}119120 for h3 in soup.select("h3.elementor-heading-title"):121 txt = h3.get_text(" ", strip=True)122 if _ADDR_RE.match(txt):123 out["address"] = txt124 break125126 desc_h2 = next((h for h in soup.select("h2")127 if h.get_text(strip=True).lower() == "description"), None)128 if desc_h2:129 parts: list[str] = []130 for el in desc_h2.find_all_next(["p", "li", "h2"]):131 if el.name == "h2":132 break133 t = re.sub(r"\s+", " ", el.get_text(" ", strip=True))134 if t and t not in parts:135 parts.append(t)136 if parts:137 out["description"] = "\n".join(parts)[:2000]138139 images: list[str] = []140 for a in soup.select(".elementor-widget-image-carousel a[href]"):141 u = a["href"]142 if u.startswith("http") and _IMG_EXT_RE.search(u) and u not in images:143 images.append(u)144 out["images"] = images[:30]145 return out146147 def _apply_detail(self, lst: Listing, d: dict) -> None:148 """Reporte le payload (frais/cache) sur l'annonce."""149 if not d:150 return151 if d.get("address"):152 lst.address = d["address"]153 if d.get("description"):154 lst.description = d["description"]155 if d.get("images"):156 lst.images = list(dict.fromkeys(d["images"] + lst.images))[:30]157