# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/per.py : connecteur Les Immeubles Paul-E. Richard # (immeublesper.com — 18 immeubles à Limoilou, Charlesbourg et Beauport). # Les unités en vedette sont listées sur /logements/ ; chaque fiche # /logement// fournit secteur, adresse, format, prix, disponibilité, # caractéristiques et galerie de photos. # ----------------------------------------------------------------------------- from __future__ import annotations import re from bs4 import BeautifulSoup from ..schema import Listing, infer_city, normalize_unit_type, parse_price from .base import BaseConnector BASE = "https://immeublesper.com" LIST_URL = f"{BASE}/logements/" _SIZE_SUFFIX = re.compile(r"-\d{2,4}x\d{2,4}(?=\.(?:jpg|jpeg|png|webp)$)", re.I) _IMG_BLACKLIST = re.compile(r"ico-|logo|slide-\d|favicon", re.I) class PERConnector(BaseConnector): source_id = "per" request_delay = 0.6 max_details = 60 # garde-fou de fetch des fiches def fetch(self) -> list[Listing]: html = self.get(LIST_URL).text slugs: list[str] = [] for m in re.finditer(r'href="https://immeublesper\.com/logement/([^/"]+)/?"', html): if m.group(1) not in slugs: slugs.append(m.group(1)) listings: list[Listing] = [] for slug in slugs[:self.max_details]: try: lst = self._parse_detail(slug) if lst: listings.append(lst) except Exception: continue return listings # -- fiche /logement// ------------------------------------------------- def _parse_detail(self, slug: str) -> Listing | None: url = f"{BASE}/logement/{slug}/" html = self.get(url).text soup = BeautifulSoup(html, "html.parser") # Informations générales : paires "ls-label"/"ls-data" info: dict[str, str] = {} for div in soup.select("div.ls-info"): label = div.select_one("span.ls-label") data = div.select_one("span.ls-data") if label and data: key = label.get_text(strip=True).rstrip(":").lower() info[key] = data.get_text(" ", strip=True) unit_raw = info.get("format", "") # exclusions : stationnement / commercial / rangement blob = " ".join([slug, unit_raw] + list(info.values())) if re.search(r"stationnement|commercial|rangement|garage|entrep[oô]t", unit_raw + " " + slug, re.I): return None sector = info.get("secteur", "") address = info.get("adresse", "") price_label = info.get("prix", "") availability = info.get("disponibilité", info.get("disponibilite", "")) # numéro d'unité : texte "#3" entre les ls-info unit_no = "" details_div = soup.select_one("div.ls-single-details") if details_div: m = re.search(r"#\s*([\w\-]+)", details_div.get_text(" ", strip=True)) if m: unit_no = f"#{m.group(1)}" # caractéristiques amenities: list[str] = [] for h2 in soup.find_all("h2"): if "caract" in h2.get_text(strip=True).lower(): ul = h2.find_next("ul") if ul: for li in ul.find_all("li"): t = re.sub(r"\s+", " ", li.get_text(" ", strip=True)) if t and not t.startswith("N.B.") and t not in amenities: amenities.append(t) break for key, lbl in (("nombre de chambres", "chambre(s)"), ("étage", "étage"), ("etage", "étage")): if info.get(key): amenities.append(f"{info[key]} {lbl}") # Animaux : champ structuré "Animaux permis: Oui/Non" de la fiche. # Valeur explicite (prioritaire sur la dérivation) — le texte brut # "Animaux permis : Non" serait sinon mal lu par la normalisation. pets = None pets_raw = (info.get("animaux permis") or "").strip() if pets_raw: amenities.append(f"Animaux permis : {pets_raw}") low = pets_raw.lower() if low.startswith("non"): pets = "non" elif low.startswith("oui"): pets = "oui" else: pets = "conditions" # images : galerie fancybox + image principale (pleine taille, dédupliquées) images: list[str] = [] for a in soup.select("div.gallerie a[href], a.fancybox-thumb[href]"): u = a.get("href", "") if re.search(r"\.(?:jpg|jpeg|png|webp)$", u, re.I): u = _SIZE_SUFFIX.sub("", u) if u.startswith("/"): u = BASE + u if u.startswith("http") and not _IMG_BLACKLIST.search(u) and u not in images: images.append(u) main = soup.select_one("div.ls-single-image img[src]") if main: u = _SIZE_SUFFIX.sub("", main["src"]) if u.startswith("http") and not _IMG_BLACKLIST.search(u) and u not in images: images.insert(0, u) if not images: # repli : toutes les images d'uploads de la page for u in re.findall(r'https://immeublesper\.com/wp-content/uploads/' r'[^"\'\s]+\.(?:jpg|jpeg|png|webp)', html, re.I): u = _SIZE_SUFFIX.sub("", u) if not _IMG_BLACKLIST.search(u) and u not in images: images.append(u) title = ", ".join(x for x in (address or slug.replace("-", " "), unit_no, unit_raw) if x) return Listing( source=self.source_id, external_id=slug, url=url, title=title, address=address, sector=sector, city=infer_city(sector, default="Québec"), unit_type=normalize_unit_type(unit_raw), price=parse_price(price_label), price_label=price_label, availability=availability, pets=pets, amenities=amenities, images=images[:25], )