# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/laureat_richard.py : connecteur Logements Lauréat Richard # (laureatrichard.com — Sherbrooke, quartier Nord, 700+ appartements gérés). # WordPress + thème Avada (fusion) + plugin WP-Property : page # /nos-immeubles-a-logements/ -> 8 pages immeuble (Le Plateau Richard, # Complexe Lunik, Carré McGregor…), chacune listant ses modèles de logements # offerts (`.property_div` : titre + lien + plan). Fiches /proprietes/ # via self.detail() (cache BD) : bloc structuré `#wpp_property_stats` # (Grandeur, Inclus dans le logement, Adresse), description et plans. # LE SITE NE PUBLIE NI PRIX NI DATE DE DISPONIBILITÉ : ces champs restent # vides (jamais de valeur inventée) — contact par téléphone/visite. # ----------------------------------------------------------------------------- from __future__ import annotations import hashlib import re from bs4 import BeautifulSoup from ..schema import Listing, normalize_unit_type, strip_accents from .base import BaseConnector BASE = "https://laureatrichard.com" LIST_URL = f"{BASE}/nos-immeubles-a-logements/" _PROP_RE = re.compile(r"/proprietes/([\w\-%]+)/?$") class LaureatRichardConnector(BaseConnector): source_id = "laureat_richard" request_delay = 0.6 max_details = 40 # garde-fou : vraies requêtes de fiches propriété def fetch(self) -> list[Listing]: # 1) Page index -> pages immeuble (URLs normalisées, dédupliquées) html = self.get(LIST_URL).text soup = BeautifulSoup(html, "html.parser") building_urls: list[str] = [] for a in soup.select('a[href*="/nos-immeubles-a-logements/"]'): url = (a.get("href") or "").split("?")[0].rstrip("/") + "/" if url != LIST_URL and url.startswith(LIST_URL) and url not in building_urls: building_urls.append(url) # 2) Pages immeuble : nom, description, particularités + modèles offerts listings: dict[str, Listing] = {} for burl in building_urls: try: bhtml = self.get(burl).text except Exception: continue bsoup = BeautifulSoup(bhtml, "html.parser") h1 = bsoup.select_one("h1.entry-title, h1") building = h1.get_text(" ", strip=True) if h1 else "" b_desc = self._text_after_heading(bsoup, r"Description des immeubles") b_amen = self._particularites(bsoup) for item in bsoup.select("div.property_div"): link = item.select_one("li.property_title a[href], a[href]") if not link: continue url = link["href"] m = _PROP_RE.search(url) if not m: continue slug = m.group(1) if slug in listings: continue title = link.get_text(" ", strip=True) thumb = item.select_one("img[src]") images = ([thumb["src"]] if thumb and thumb.get("src", "").startswith("http") else []) listings[slug] = Listing( source=self.source_id, external_id=slug, url=url, title=title or slug.replace("-", " "), city="Sherbrooke", unit_type=normalize_unit_type(title), description=b_desc[:600], amenities=list(b_amen), details={"building": building} if building else {}, images=images, ) # 3) Fiches propriété (cache BD) : Grandeur / Inclus / Adresse, # description du logement, plans self._fetched = 0 for lst in listings.values(): key = hashlib.sha1(f"{lst.title}|{lst.url}".encode("utf-8")).hexdigest() def fetch_fn(u=lst.url): if self._fetched >= self.max_details: raise RuntimeError("budget de fiches détail atteint") self._fetched += 1 return self._fetch_detail(u) try: payload = self.detail(lst.external_id, key, fetch_fn) except Exception: continue self._apply_detail(lst, payload) return list(listings.values()) # -- blocs Avada (fusion) ----------------------------------------------------- @staticmethod def _text_after_heading(soup, pattern: str) -> str: """Texte du bloc fusion-text qui suit un titre donné (page immeuble).""" h = soup.find(["h2", "h3"], string=re.compile(pattern)) if not h: return "" cont = h.find_parent(class_=re.compile("fusion-title")) or h node = cont for _ in range(4): node = node.find_next_sibling() if node is None: break if "fusion-text" in (node.get("class") or []): return " ".join(node.get_text(" ", strip=True).split()) return "" @staticmethod def _particularites(soup) -> list[str]: """Items de la liste « Particularités » de l'immeuble.""" h = soup.find("h3", string=re.compile(r"^\s*Particularités\s*$")) if not h: return [] cont = h.find_parent(class_=re.compile("fusion-title")) or h node = cont for _ in range(4): node = node.find_next_sibling() if node is None: break if "fusion-text" in (node.get("class") or []): items = [li.get_text(" ", strip=True) for li in node.select("li")] items = [i for i in items if i] if items: return items[:15] txt = " ".join(node.get_text(" ", strip=True).split()) return [txt] if txt else [] return [] # -- fiche propriété (WP-Property) --------------------------------------------- def _fetch_detail(self, url: str) -> dict: html = self.get(url).text soup = BeautifulSoup(html, "html.parser") out: dict = {} # stats structurées : Grandeur / Inclus dans le logement / Adresse for li in soup.select("#wpp_property_stats li"): lab_el = li.select_one("span.attribute") val_el = li.select_one("span.value") if not (lab_el and val_el): continue lab = strip_accents(lab_el.get_text(" ", strip=True).lower()) val = " ".join(val_el.get_text(" ", strip=True).split()).rstrip(",") if lab.startswith("grandeur"): out["grandeur"] = val elif lab.startswith("inclus"): out["inclus"] = [x.strip() for x in val.split(",") if x.strip()] elif lab.startswith("adresse"): out["address"] = val # description du logement (paragraphes après le titre wpp_title) title_el = soup.find("div", class_="wpp_title", string=re.compile("Description du logement")) if title_el: paras = [] node = title_el for _ in range(8): node = node.find_next_sibling() if node is None or node.name not in ("p",): break t = " ".join(node.get_text(" ", strip=True).split()) if t: paras.append(t) if paras: out["description"] = " ".join(paras)[:1200] # plans des modèles (grande taille via les liens de la galerie) images: list[str] = [] for a in soup.select('a[href*="/wp-content/uploads/"]'): href = a["href"] if re.search(r"\.(jpe?g|png|webp)$", href, re.I) and href not in images: images.append(href) for img in soup.select('img[src*="/wp-content/uploads/"]'): src = img["src"] if (re.search(r"\.(jpe?g|png|webp)$", src, re.I) and not re.search(r"logo", src, re.I) and src not in images): images.append(src) out["images"] = images[:20] return out def _apply_detail(self, lst: Listing, d: dict) -> None: if not d: return if d.get("address"): # « 2975, rue Richard, Sherbrooke, QC J1L 2X5, Canada » lst.address = re.sub(r",?\s*Canada$", "", d["address"]) if d.get("grandeur"): lst.unit_type = normalize_unit_type(d["grandeur"]) or lst.unit_type if d.get("inclus"): lst.amenities = list(dict.fromkeys(d["inclus"] + lst.amenities)) if d.get("description"): # description propre au logement d'abord, contexte immeuble ensuite building_desc = lst.description lst.description = (d["description"] + (f" | {building_desc}" if building_desc else ""))[:1200] if d.get("images"): lst.images = list(dict.fromkeys(d["images"] + lst.images))[:20]