# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/immeubles_pm.py : connecteur Les Immeubles PM (lesimmeublespm.com) # Parc familial de Sherbrooke (Fleurimont, Sherbrooke Est) — 6 immeubles. # Plateforme « plogg solutions » (même famille que appartementsgranby.com / # godbout, resté non connectable faute d'unités libres) : la page d'accueil # rend côté serveur le bloc #listing-appart — une carte .box par immeuble # AVEC UNITÉS LIBRES seulement, contenant un tableau Grandeur / Loyer # (« à partir de 1475$ ») / Disponible + lien « Consulter la fiche ». # La recherche (form-manager.php) est fermée aux robots, mais les fiches # /appartement// restent servies : self.detail (cache BD) y lit le # tableau complet (rue, ville, quartier, superficie, étage, salles de bain, # état), la description (div.extrait) et la galerie. external_id = slug de # la fiche (stable, un par unité-type d'immeuble). # ----------------------------------------------------------------------------- from __future__ import annotations import hashlib import re from bs4 import BeautifulSoup from ..schema import Listing, normalize_unit_type, parse_price, strip_accents from .base import BaseConnector BASE = "https://www.lesimmeublespm.com" _SIZE_SUFFIX = re.compile(r"-\d{2,4}x\d{2,4}(?=\.(?:jpg|jpeg|png|webp)$)", re.I) def _clean_unit_type(raw: str) -> str: ut = normalize_unit_type(raw or "") if re.fullmatch(r"\d½\+?|6½\+|Studio|Loft|Chambre|Maison", ut or ""): return ut return "" class ImmeublesPMConnector(BaseConnector): source_id = "immeubles_pm" request_delay = 0.7 max_details = 25 # garde-fou fiches détail (vraies requêtes) def fetch(self) -> list[Listing]: html = self.get(BASE + "/").text soup = BeautifulSoup(html, "html.parser") listings: dict[str, Listing] = {} listing_root = soup.select_one("#listing-appart") or soup for box in listing_root.select("div.box"): try: self._parse_box(box, listings) except Exception: continue # fiches détail (cache BD) : adresse, superficie, étage, description… self._fetched = 0 for lst in listings.values(): card_key = hashlib.sha1( f"{lst.title}|{lst.price_label}|{lst.availability}" .encode("utf-8")).hexdigest() try: payload = self.detail(lst.external_id, card_key, lambda u=lst.url: self._fetch_detail(u)) except Exception: continue self._apply_detail(lst, payload) return list(listings.values()) # -- carte immeuble de l'accueil (tableau des unités libres) ----------------- def _parse_box(self, box, listings: dict[str, Listing]) -> None: title_el = box.select_one(".box-content-title") building = title_el.get_text(" ", strip=True) if title_el else "" quartier = "" q_el = box.select_one(".box-content-quartier") if q_el: quartier = re.sub(r"^\s*Quartier\s*:?\s*", "", q_el.get_text(" ", strip=True), flags=re.I).strip() photo = "" ph_el = box.select_one(".photo-wrapper[style]") if ph_el: m = re.search(r"url\('([^']+)'\)", ph_el.get("style") or "") if m: photo = _SIZE_SUFFIX.sub("", m.group(1)) # tableau bureau (les blocs mobiles dupliquent les mêmes unités) for row in box.select(".hide-on-mobile table tbody tr"): link = row.select_one('a[href*="/appartement/"]') if not link: continue url = link["href"] m = re.search(r"/appartement/([^/?#]+)", url) if not m: continue ext_id = m.group(1) if ext_id in listings: continue cells = row.find_all("td") grandeur = cells[0].get_text(" ", strip=True) if cells else "" price_label = re.sub(r"\s+", " ", cells[1].get_text( " ", strip=True)).strip() if len(cells) > 1 else "" availability = re.sub(r"\s+", " ", cells[2].get_text( " ", strip=True)).strip() if len(cells) > 2 else "" if re.search(r"complet|lou[ée]\b", availability, re.I): continue title = f"{grandeur} — {building}".strip(" —") listings[ext_id] = Listing( source=self.source_id, external_id=ext_id, url=url, title=re.sub(r"\s+", " ", title).strip(), address=building, # nom d'immeuble = adresse civique sector=quartier, city="Sherbrooke", # précisé par la fiche (champ Ville) unit_type=_clean_unit_type(grandeur), price=parse_price(price_label), price_label=price_label, availability=availability, images=[photo] if photo else [], ) # -- fiche /appartement// ----------------------------------------------- def _fetch_detail(self, url: str) -> dict: 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 = {} # tableau infos-appart : paires Champvaleur pairs: dict[str, str] = {} for row in soup.select(".infos-appart table tr"): cells = row.find_all("td") if len(cells) < 2: continue lab = strip_accents(cells[0].get_text(" ", strip=True).lower()) val = re.sub(r"\s+", " ", cells[1].get_text(" ", strip=True)).strip() val = re.sub(r"\bpi\s*2\b", "pi²", val) # « 1510 pi2 » if lab and val: pairs[lab] = val if pairs.get("rue"): out["address"] = pairs["rue"] if pairs.get("ville"): out["city"] = pairs["ville"] if pairs.get("quartier"): out["sector"] = pairs["quartier"] if pairs.get("grandeur"): out["grandeur"] = pairs["grandeur"] if pairs.get("etat"): out["etat"] = pairs["etat"] amenities = [] for lab, label in [("superficie", "Superficie"), ("etage", "Étage"), ("nombre de salle de bain", "Salle(s) de bain")]: if pairs.get(lab): amenities.append(f"{label} : {pairs[lab]}") out["amenities"] = amenities # description : bloc div.extrait (texte + liste d'inclusions) ext = soup.select_one("div.extrait") if ext: txt = ext.get_text("\n", strip=True) out["description"] = re.sub(r"[ \t]+", " ", txt).strip()[:1500] # galerie : photos jpg des téléversements (icônes png exclues) images: list[str] = [] for u in re.findall( r"https://www\.lesimmeublespm\.com/wp-content/uploads/" r"[^\"'()\s]+\.(?:jpg|jpeg|webp)", html, re.I): u = _SIZE_SUFFIX.sub("", u) if 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 if d.get("address"): lst.address = d["address"] if d.get("city"): lst.city = d["city"] if d.get("sector") and not lst.sector: lst.sector = d["sector"] if not lst.unit_type and d.get("grandeur"): lst.unit_type = _clean_unit_type(d["grandeur"]) if not lst.availability and d.get("etat"): lst.availability = d["etat"] if d.get("amenities"): lst.amenities = list(dict.fromkeys(lst.amenities + d["amenities"])) if d.get("description"): lst.description = d["description"] if d.get("images"): merged = list(dict.fromkeys(d["images"] + lst.images)) lst.images = merged[:30]