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/moderno.py : connecteur Moderno Construction (moderno.immo)5# Promoteur-gestionnaire de Lanaudière (Joliette — Aqua Roca, Domaine du6# Sentier Riverain). Site custom SSR très propre : /logements-a-louer liste7# des cartes BEM (titre, disponibilité, prix, adresse, badge « Loué »),8# chaque carte pointe vers /logements-a-louer/<projet>/<CODE> ; le CODE9# (= champ « Référence » de la fiche) sert d'external_id stable. La fiche10# détail (via cache BD) ajoute chambres/sdb, unité/niveau/vue/superficie,11# inclusions, commodités, description et galerie.12# -----------------------------------------------------------------------------13from __future__ import annotations1415import hashlib16import re1718from bs4 import BeautifulSoup1920from ..schema import Listing, normalize_unit_type, parse_price21from .base import BaseConnector2223BASE = "https://moderno.immo"24LIST_URL = f"{BASE}/logements-a-louer"2526_CARD = "appartements-a-louer__liste__item"27_DET = "details-appartement__details"2829# vignettes DigitalOcean Spaces : ".../<id>/conversions/<nom>-thumb.jpg"30# -> pleine taille ".../<id>/<nom>.jpg" (même schéma que les images de cartes)31_THUMB_RE = re.compile(r"/conversions/(.+?)-(?:first_)?thumb(\.\w+)$")323334def _full_img(url: str) -> str:35 return _THUMB_RE.sub(r"/\1\2", url.strip())363738class ModernoConnector(BaseConnector):39 source_id = "moderno"40 request_delay = 0.741 max_details = 40 # garde-fou fiches détail (vraies requêtes par sync)4243 def fetch(self) -> list[Listing]:44 html = self.get(LIST_URL).text45 soup = BeautifulSoup(html, "html.parser")46 listings: dict[str, Listing] = {}47 for card in soup.select(f"a.{_CARD}[href]"):48 try:49 self._parse_card(card, listings)50 except Exception:51 continue5253 self._fetched = 054 for lst in listings.values():55 key = hashlib.sha1(56 f"{lst.title}|{lst.price_label}|{lst.availability}|{lst.address}"57 .encode("utf-8")).hexdigest()58 try:59 payload = self.detail(lst.external_id, key,60 lambda u=lst.url: self._fetch_detail(u))61 except Exception:62 continue63 self._apply_detail(lst, payload)64 return list(listings.values())6566 # -- carte liste ------------------------------------------------------------67 def _parse_card(self, card, listings: dict[str, Listing]) -> None:68 url = card["href"]69 if url.startswith("/"):70 url = BASE + url71 m = re.search(r"/logements-a-louer/([^/]+)/([A-Z0-9]+)/?$", url)72 if not m:73 return74 project_slug, code = m.group(1), m.group(2)75 if code in listings:76 return7778 def _txt(suffix: str) -> str:79 el = card.select_one(f".{_CARD}__{suffix}")80 return re.sub(r"\s+", " ", el.get_text(" ", strip=True)) if el else ""8182 # badge « Loué » : annonce déjà partie, on l'ignore83 if re.search(r"lou[ée]", _txt("rented"), re.I):84 return8586 title = _txt("titre") # « 4 1/2 - Domaine du Sentier Riverain »87 address_full = _txt("adresse") # « 105-1002 rue Gustave-Guertin, Joliette »88 parts = [p.strip() for p in address_full.split(",") if p.strip()]89 address = re.sub(r"\s+", " ", parts[0]) if parts else ""90 city = parts[-1] if len(parts) > 1 else ""91 price_label = _txt("prix") # « 1 915 $ par mois »92 message = _txt("message") # accroche rédigée par Moderno9394 images: list[str] = []95 img = card.select_one("img[src]")96 if img and img["src"].startswith("http"):97 images.append(_full_img(img["src"]))9899 listings[code] = Listing(100 source=self.source_id,101 external_id=code, # champ « Référence » de la fiche102 url=url,103 title=title,104 address=address,105 city=city,106 unit_type=normalize_unit_type(title),107 price=parse_price(price_label),108 price_label=price_label,109 availability=_txt("disponibilite"), # « Disponible 1er octobre 2026 »110 description=message,111 details={"project": project_slug},112 images=images,113 )114115 # -- fiche détail -------------------------------------------------------------116 def _fetch_detail(self, url: str) -> dict:117 """Chambres/sdb, chiffres (unité, niveau, vue, superficie, dispo),118 inclusions + commodités, description et galerie pleine taille."""119 if self._fetched >= self.max_details:120 raise RuntimeError("budget de fiches détail atteint")121 self._fetched += 1122 html = self.get(url).text123 soup = BeautifulSoup(html, "html.parser")124 out: dict = {}125126 det = soup.select_one(f"section.{_DET}")127 if det is None:128 return out129130 # adresse complète de la fiche (parfois plus propre que la carte)131 addr = det.select_one(f".{_DET}__adresse")132 if addr:133 out["address_full"] = re.sub(r"\s+", " ", addr.get_text(" ", strip=True))134135 # « 1 chambre », « 1 salle de bain »136 out["pieces"] = [re.sub(r"\s+", " ", p.get_text(" ", strip=True))137 for p in det.select(f".{_DET}__pieces__piece")]138139 # paires libellé/valeur : Unité, Niveau, Vue, Superficie, Disponibilité, Référence140 chiffres: dict[str, str] = {}141 for div in det.select(f".{_DET}__chiffres > div"):142 ps = div.find_all("p")143 if len(ps) >= 2:144 lab = ps[0].get_text(" ", strip=True)145 val = re.sub(r"\s+", " ", ps[1].get_text(" ", strip=True))146 if lab and val:147 chiffres[lab] = val148 out["chiffres"] = chiffres149150 # Inclusions (eau chaude, internet…) et Commodités (logement + immeuble)151 feats: list[str] = []152 for box in det.select(f".{_DET}__inclusions, .{_DET}__commodites"):153 items = [re.sub(r"\s+", " ", li.get_text(" ", strip=True))154 for li in box.find_all("li")]155 if not items: # listes parfois en <p>156 items = [re.sub(r"\s+", " ", p.get_text(" ", strip=True))157 for p in box.find_all("p")][1:]158 head = box.find(["h4", "h5", "div"])159 head_txt = head.get_text(" ", strip=True) if head else ""160 if re.match(r"(?i)localisation", head_txt):161 continue # adresse déjà captée162 for it in items:163 if it and it not in feats and not re.match(r"(?i)inclusions|commodit|caractéristiques|localisation", it):164 feats.append(it)165 out["features"] = feats[:40]166167 # description : accroche + paragraphes rédigés de la fiche168 msg = det.select_one(f".{_DET}__message")169 paras = [msg.get_text(" ", strip=True)] if msg else []170 for p in det.find_all("p"):171 t = re.sub(r"\s+", " ", p.get_text(" ", strip=True))172 if len(t) > 60 and t not in paras:173 paras.append(t)174 out["description"] = "\n".join(paras)[:1200]175176 # galerie (vignettes /conversions/ -> pleine taille)177 images: list[str] = []178 for img in soup.select(".details-appartement__introduction__galerie img[src]"):179 u = _full_img(img["src"])180 if u.startswith("http") and u not in images:181 images.append(u)182 out["images"] = images[:30]183 return out184185 def _apply_detail(self, lst: Listing, d: dict) -> None:186 if not d:187 return188 ch = d.get("chiffres") or {}189 details = dict(lst.details)190 for lab, key in (("Unité", "unit_number"), ("Niveau", "floor"),191 ("Vue", "view"), ("Référence", "reference")):192 if ch.get(lab):193 details[key] = ch[lab]194 lst.details = details195 m = re.match(r"([\d\s,.]+)\s*pi", ch.get("Superficie", ""))196 if m:197 try:198 lst.area_sqft = float(m.group(1).replace(" ", "").replace(",", ""))199 except ValueError:200 pass201 if ch.get("Disponibilité") and not lst.availability:202 lst.availability = ch["Disponibilité"]203 if d.get("address_full") and not lst.address:204 parts = [p.strip() for p in d["address_full"].split(",")]205 lst.address = parts[0]206 if len(parts) > 1 and not lst.city:207 lst.city = parts[-1]208 extra = (d.get("pieces") or []) + (d.get("features") or [])209 if extra:210 lst.amenities = list(dict.fromkeys(lst.amenities + extra))211 if d.get("description"):212 lst.description = d["description"]213 if d.get("images"):214 merged = d["images"] + [u for u in lst.images if u not in d["images"]]215 lst.images = merged[:30]216