# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/moderno.py : connecteur Moderno Construction (moderno.immo) # Promoteur-gestionnaire de Lanaudière (Joliette — Aqua Roca, Domaine du # Sentier Riverain). Site custom SSR très propre : /logements-a-louer liste # des cartes BEM (titre, disponibilité, prix, adresse, badge « Loué »), # chaque carte pointe vers /logements-a-louer// ; le CODE # (= champ « Référence » de la fiche) sert d'external_id stable. La fiche # détail (via cache BD) ajoute chambres/sdb, unité/niveau/vue/superficie, # inclusions, commodités, description et galerie. # ----------------------------------------------------------------------------- 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://moderno.immo" LIST_URL = f"{BASE}/logements-a-louer" _CARD = "appartements-a-louer__liste__item" _DET = "details-appartement__details" # vignettes DigitalOcean Spaces : "...//conversions/-thumb.jpg" # -> pleine taille "...//.jpg" (même schéma que les images de cartes) _THUMB_RE = re.compile(r"/conversions/(.+?)-(?:first_)?thumb(\.\w+)$") def _full_img(url: str) -> str: return _THUMB_RE.sub(r"/\1\2", url.strip()) class ModernoConnector(BaseConnector): source_id = "moderno" request_delay = 0.7 max_details = 40 # garde-fou fiches détail (vraies requêtes par sync) def fetch(self) -> list[Listing]: html = self.get(LIST_URL).text soup = BeautifulSoup(html, "html.parser") listings: dict[str, Listing] = {} for card in soup.select(f"a.{_CARD}[href]"): try: self._parse_card(card, listings) except Exception: continue self._fetched = 0 for lst in listings.values(): key = hashlib.sha1( f"{lst.title}|{lst.price_label}|{lst.availability}|{lst.address}" .encode("utf-8")).hexdigest() try: payload = self.detail(lst.external_id, key, lambda u=lst.url: self._fetch_detail(u)) except Exception: continue self._apply_detail(lst, payload) return list(listings.values()) # -- carte liste ------------------------------------------------------------ def _parse_card(self, card, listings: dict[str, Listing]) -> None: url = card["href"] if url.startswith("/"): url = BASE + url m = re.search(r"/logements-a-louer/([^/]+)/([A-Z0-9]+)/?$", url) if not m: return project_slug, code = m.group(1), m.group(2) if code in listings: return def _txt(suffix: str) -> str: el = card.select_one(f".{_CARD}__{suffix}") return re.sub(r"\s+", " ", el.get_text(" ", strip=True)) if el else "" # badge « Loué » : annonce déjà partie, on l'ignore if re.search(r"lou[ée]", _txt("rented"), re.I): return title = _txt("titre") # « 4 1/2 - Domaine du Sentier Riverain » address_full = _txt("adresse") # « 105-1002 rue Gustave-Guertin, Joliette » parts = [p.strip() for p in address_full.split(",") if p.strip()] address = re.sub(r"\s+", " ", parts[0]) if parts else "" city = parts[-1] if len(parts) > 1 else "" price_label = _txt("prix") # « 1 915 $ par mois » message = _txt("message") # accroche rédigée par Moderno images: list[str] = [] img = card.select_one("img[src]") if img and img["src"].startswith("http"): images.append(_full_img(img["src"])) listings[code] = Listing( source=self.source_id, external_id=code, # champ « Référence » de la fiche url=url, title=title, address=address, city=city, unit_type=normalize_unit_type(title), price=parse_price(price_label), price_label=price_label, availability=_txt("disponibilite"), # « Disponible 1er octobre 2026 » description=message, details={"project": project_slug}, images=images, ) # -- fiche détail ------------------------------------------------------------- def _fetch_detail(self, url: str) -> dict: """Chambres/sdb, chiffres (unité, niveau, vue, superficie, dispo), inclusions + commodités, description et galerie pleine taille.""" if self._fetched >= self.max_details: raise RuntimeError("budget de fiches détail atteint") self._fetched += 1 html = self.get(url).text soup = BeautifulSoup(html, "html.parser") out: dict = {} det = soup.select_one(f"section.{_DET}") if det is None: return out # adresse complète de la fiche (parfois plus propre que la carte) addr = det.select_one(f".{_DET}__adresse") if addr: out["address_full"] = re.sub(r"\s+", " ", addr.get_text(" ", strip=True)) # « 1 chambre », « 1 salle de bain » out["pieces"] = [re.sub(r"\s+", " ", p.get_text(" ", strip=True)) for p in det.select(f".{_DET}__pieces__piece")] # paires libellé/valeur : Unité, Niveau, Vue, Superficie, Disponibilité, Référence chiffres: dict[str, str] = {} for div in det.select(f".{_DET}__chiffres > div"): ps = div.find_all("p") if len(ps) >= 2: lab = ps[0].get_text(" ", strip=True) val = re.sub(r"\s+", " ", ps[1].get_text(" ", strip=True)) if lab and val: chiffres[lab] = val out["chiffres"] = chiffres # Inclusions (eau chaude, internet…) et Commodités (logement + immeuble) feats: list[str] = [] for box in det.select(f".{_DET}__inclusions, .{_DET}__commodites"): items = [re.sub(r"\s+", " ", li.get_text(" ", strip=True)) for li in box.find_all("li")] if not items: # listes parfois en

items = [re.sub(r"\s+", " ", p.get_text(" ", strip=True)) for p in box.find_all("p")][1:] head = box.find(["h4", "h5", "div"]) head_txt = head.get_text(" ", strip=True) if head else "" if re.match(r"(?i)localisation", head_txt): continue # adresse déjà captée for it in items: if it and it not in feats and not re.match(r"(?i)inclusions|commodit|caractéristiques|localisation", it): feats.append(it) out["features"] = feats[:40] # description : accroche + paragraphes rédigés de la fiche msg = det.select_one(f".{_DET}__message") paras = [msg.get_text(" ", strip=True)] if msg else [] for p in det.find_all("p"): t = re.sub(r"\s+", " ", p.get_text(" ", strip=True)) if len(t) > 60 and t not in paras: paras.append(t) out["description"] = "\n".join(paras)[:1200] # galerie (vignettes /conversions/ -> pleine taille) images: list[str] = [] for img in soup.select(".details-appartement__introduction__galerie img[src]"): u = _full_img(img["src"]) if u.startswith("http") and u not in images: images.append(u) out["images"] = images[:30] return out def _apply_detail(self, lst: Listing, d: dict) -> None: if not d: return ch = d.get("chiffres") or {} details = dict(lst.details) for lab, key in (("Unité", "unit_number"), ("Niveau", "floor"), ("Vue", "view"), ("Référence", "reference")): if ch.get(lab): details[key] = ch[lab] lst.details = details m = re.match(r"([\d\s,.]+)\s*pi", ch.get("Superficie", "")) if m: try: lst.area_sqft = float(m.group(1).replace(" ", "").replace(",", "")) except ValueError: pass if ch.get("Disponibilité") and not lst.availability: lst.availability = ch["Disponibilité"] if d.get("address_full") and not lst.address: parts = [p.strip() for p in d["address_full"].split(",")] lst.address = parts[0] if len(parts) > 1 and not lst.city: lst.city = parts[-1] extra = (d.get("pieces") or []) + (d.get("features") or []) if extra: lst.amenities = list(dict.fromkeys(lst.amenities + extra)) if d.get("description"): lst.description = d["description"] if d.get("images"): merged = d["images"] + [u for u in lst.images if u not in d["images"]] lst.images = merged[:30]