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/immeubles_pm.py : connecteur Les Immeubles PM (lesimmeublespm.com)5# Parc familial de Sherbrooke (Fleurimont, Sherbrooke Est) — 6 immeubles.6# Plateforme « plogg solutions » (même famille que appartementsgranby.com /7# godbout, resté non connectable faute d'unités libres) : la page d'accueil8# rend côté serveur le bloc #listing-appart — une carte .box par immeuble9# AVEC UNITÉS LIBRES seulement, contenant un tableau Grandeur / Loyer10# (« à partir de 1475$ ») / Disponible + lien « Consulter la fiche ».11# La recherche (form-manager.php) est fermée aux robots, mais les fiches12# /appartement/<slug>/ restent servies : self.detail (cache BD) y lit le13# tableau complet (rue, ville, quartier, superficie, étage, salles de bain,14# état), la description (div.extrait) et la galerie. external_id = slug de15# la fiche (stable, un par unité-type d'immeuble).16# -----------------------------------------------------------------------------17from __future__ import annotations1819import hashlib20import re2122from bs4 import BeautifulSoup2324from ..schema import Listing, normalize_unit_type, parse_price, strip_accents25from .base import BaseConnector2627BASE = "https://www.lesimmeublespm.com"2829_SIZE_SUFFIX = re.compile(r"-\d{2,4}x\d{2,4}(?=\.(?:jpg|jpeg|png|webp)$)", re.I)303132def _clean_unit_type(raw: str) -> str:33 ut = normalize_unit_type(raw or "")34 if re.fullmatch(r"\d½\+?|6½\+|Studio|Loft|Chambre|Maison", ut or ""):35 return ut36 return ""373839class ImmeublesPMConnector(BaseConnector):40 source_id = "immeubles_pm"41 request_delay = 0.742 max_details = 25 # garde-fou fiches détail (vraies requêtes)4344 def fetch(self) -> list[Listing]:45 html = self.get(BASE + "/").text46 soup = BeautifulSoup(html, "html.parser")4748 listings: dict[str, Listing] = {}49 listing_root = soup.select_one("#listing-appart") or soup50 for box in listing_root.select("div.box"):51 try:52 self._parse_box(box, listings)53 except Exception:54 continue5556 # fiches détail (cache BD) : adresse, superficie, étage, description…57 self._fetched = 058 for lst in listings.values():59 card_key = hashlib.sha1(60 f"{lst.title}|{lst.price_label}|{lst.availability}"61 .encode("utf-8")).hexdigest()62 try:63 payload = self.detail(lst.external_id, card_key,64 lambda u=lst.url: self._fetch_detail(u))65 except Exception:66 continue67 self._apply_detail(lst, payload)68 return list(listings.values())6970 # -- carte immeuble de l'accueil (tableau des unités libres) -----------------71 def _parse_box(self, box, listings: dict[str, Listing]) -> None:72 title_el = box.select_one(".box-content-title")73 building = title_el.get_text(" ", strip=True) if title_el else ""74 quartier = ""75 q_el = box.select_one(".box-content-quartier")76 if q_el:77 quartier = re.sub(r"^\s*Quartier\s*:?\s*", "",78 q_el.get_text(" ", strip=True), flags=re.I).strip()7980 photo = ""81 ph_el = box.select_one(".photo-wrapper[style]")82 if ph_el:83 m = re.search(r"url\('([^']+)'\)", ph_el.get("style") or "")84 if m:85 photo = _SIZE_SUFFIX.sub("", m.group(1))8687 # tableau bureau (les blocs mobiles dupliquent les mêmes unités)88 for row in box.select(".hide-on-mobile table tbody tr"):89 link = row.select_one('a[href*="/appartement/"]')90 if not link:91 continue92 url = link["href"]93 m = re.search(r"/appartement/([^/?#]+)", url)94 if not m:95 continue96 ext_id = m.group(1)97 if ext_id in listings:98 continue99 cells = row.find_all("td")100 grandeur = cells[0].get_text(" ", strip=True) if cells else ""101 price_label = re.sub(r"\s+", " ", cells[1].get_text(102 " ", strip=True)).strip() if len(cells) > 1 else ""103 availability = re.sub(r"\s+", " ", cells[2].get_text(104 " ", strip=True)).strip() if len(cells) > 2 else ""105 if re.search(r"complet|lou[ée]\b", availability, re.I):106 continue107108 title = f"{grandeur} — {building}".strip(" —")109 listings[ext_id] = Listing(110 source=self.source_id,111 external_id=ext_id,112 url=url,113 title=re.sub(r"\s+", " ", title).strip(),114 address=building, # nom d'immeuble = adresse civique115 sector=quartier,116 city="Sherbrooke", # précisé par la fiche (champ Ville)117 unit_type=_clean_unit_type(grandeur),118 price=parse_price(price_label),119 price_label=price_label,120 availability=availability,121 images=[photo] if photo else [],122 )123124 # -- fiche /appartement/<slug>/ -----------------------------------------------125 def _fetch_detail(self, url: str) -> dict:126 if self._fetched >= self.max_details:127 raise RuntimeError("budget de fiches détail atteint")128 self._fetched += 1129 html = self.get(url).text130 soup = BeautifulSoup(html, "html.parser")131 out: dict = {}132133 # tableau infos-appart : paires <td.sub-title><b>Champ</b></td><td>valeur134 pairs: dict[str, str] = {}135 for row in soup.select(".infos-appart table tr"):136 cells = row.find_all("td")137 if len(cells) < 2:138 continue139 lab = strip_accents(cells[0].get_text(" ", strip=True).lower())140 val = re.sub(r"\s+", " ", cells[1].get_text(" ", strip=True)).strip()141 val = re.sub(r"\bpi\s*2\b", "pi²", val) # « 1510 pi<sup>2</sup> »142 if lab and val:143 pairs[lab] = val144 if pairs.get("rue"):145 out["address"] = pairs["rue"]146 if pairs.get("ville"):147 out["city"] = pairs["ville"]148 if pairs.get("quartier"):149 out["sector"] = pairs["quartier"]150 if pairs.get("grandeur"):151 out["grandeur"] = pairs["grandeur"]152 if pairs.get("etat"):153 out["etat"] = pairs["etat"]154 amenities = []155 for lab, label in [("superficie", "Superficie"),156 ("etage", "Étage"),157 ("nombre de salle de bain", "Salle(s) de bain")]:158 if pairs.get(lab):159 amenities.append(f"{label} : {pairs[lab]}")160 out["amenities"] = amenities161162 # description : bloc div.extrait (texte + liste d'inclusions)163 ext = soup.select_one("div.extrait")164 if ext:165 txt = ext.get_text("\n", strip=True)166 out["description"] = re.sub(r"[ \t]+", " ", txt).strip()[:1500]167168 # galerie : photos jpg des téléversements (icônes png exclues)169 images: list[str] = []170 for u in re.findall(171 r"https://www\.lesimmeublespm\.com/wp-content/uploads/"172 r"[^\"'()\s]+\.(?:jpg|jpeg|webp)", html, re.I):173 u = _SIZE_SUFFIX.sub("", u)174 if u not in images:175 images.append(u)176 out["images"] = images[:30]177 return out178179 def _apply_detail(self, lst: Listing, d: dict) -> None:180 if not d:181 return182 if d.get("address"):183 lst.address = d["address"]184 if d.get("city"):185 lst.city = d["city"]186 if d.get("sector") and not lst.sector:187 lst.sector = d["sector"]188 if not lst.unit_type and d.get("grandeur"):189 lst.unit_type = _clean_unit_type(d["grandeur"])190 if not lst.availability and d.get("etat"):191 lst.availability = d["etat"]192 if d.get("amenities"):193 lst.amenities = list(dict.fromkeys(lst.amenities + d["amenities"]))194 if d.get("description"):195 lst.description = d["description"]196 if d.get("images"):197 merged = list(dict.fromkeys(d["images"] + lst.images))198 lst.images = merged[:30]199