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/per.py : connecteur Les Immeubles Paul-E. Richard5# (immeublesper.com — 18 immeubles à Limoilou, Charlesbourg et Beauport).6# Les unités en vedette sont listées sur /logements/ ; chaque fiche7# /logement/<slug>/ fournit secteur, adresse, format, prix, disponibilité,8# caractéristiques et galerie de photos.9# -----------------------------------------------------------------------------10from __future__ import annotations1112import re1314from bs4 import BeautifulSoup1516from ..schema import Listing, infer_city, normalize_unit_type, parse_price17from .base import BaseConnector1819BASE = "https://immeublesper.com"20LIST_URL = f"{BASE}/logements/"2122_SIZE_SUFFIX = re.compile(r"-\d{2,4}x\d{2,4}(?=\.(?:jpg|jpeg|png|webp)$)", re.I)23_IMG_BLACKLIST = re.compile(r"ico-|logo|slide-\d|favicon", re.I)242526class PERConnector(BaseConnector):27 source_id = "per"28 request_delay = 0.629 max_details = 60 # garde-fou de fetch des fiches3031 def fetch(self) -> list[Listing]:32 html = self.get(LIST_URL).text33 slugs: list[str] = []34 for m in re.finditer(r'href="https://immeublesper\.com/logement/([^/"]+)/?"', html):35 if m.group(1) not in slugs:36 slugs.append(m.group(1))3738 listings: list[Listing] = []39 for slug in slugs[:self.max_details]:40 try:41 lst = self._parse_detail(slug)42 if lst:43 listings.append(lst)44 except Exception:45 continue46 return listings4748 # -- fiche /logement/<slug>/ -------------------------------------------------49 def _parse_detail(self, slug: str) -> Listing | None:50 url = f"{BASE}/logement/{slug}/"51 html = self.get(url).text52 soup = BeautifulSoup(html, "html.parser")5354 # Informations générales : paires "ls-label"/"ls-data"55 info: dict[str, str] = {}56 for div in soup.select("div.ls-info"):57 label = div.select_one("span.ls-label")58 data = div.select_one("span.ls-data")59 if label and data:60 key = label.get_text(strip=True).rstrip(":").lower()61 info[key] = data.get_text(" ", strip=True)6263 unit_raw = info.get("format", "")64 # exclusions : stationnement / commercial / rangement65 blob = " ".join([slug, unit_raw] + list(info.values()))66 if re.search(r"stationnement|commercial|rangement|garage|entrep[oô]t",67 unit_raw + " " + slug, re.I):68 return None6970 sector = info.get("secteur", "")71 address = info.get("adresse", "")72 price_label = info.get("prix", "")73 availability = info.get("disponibilité", info.get("disponibilite", ""))7475 # numéro d'unité : texte "#3" entre les ls-info76 unit_no = ""77 details_div = soup.select_one("div.ls-single-details")78 if details_div:79 m = re.search(r"#\s*([\w\-]+)", details_div.get_text(" ", strip=True))80 if m:81 unit_no = f"#{m.group(1)}"8283 # caractéristiques84 amenities: list[str] = []85 for h2 in soup.find_all("h2"):86 if "caract" in h2.get_text(strip=True).lower():87 ul = h2.find_next("ul")88 if ul:89 for li in ul.find_all("li"):90 t = re.sub(r"\s+", " ", li.get_text(" ", strip=True))91 if t and not t.startswith("N.B.") and t not in amenities:92 amenities.append(t)93 break94 for key, lbl in (("nombre de chambres", "chambre(s)"),95 ("étage", "étage"), ("etage", "étage")):96 if info.get(key):97 amenities.append(f"{info[key]} {lbl}")9899 # Animaux : champ structuré "Animaux permis: Oui/Non" de la fiche.100 # Valeur explicite (prioritaire sur la dérivation) — le texte brut101 # "Animaux permis : Non" serait sinon mal lu par la normalisation.102 pets = None103 pets_raw = (info.get("animaux permis") or "").strip()104 if pets_raw:105 amenities.append(f"Animaux permis : {pets_raw}")106 low = pets_raw.lower()107 if low.startswith("non"):108 pets = "non"109 elif low.startswith("oui"):110 pets = "oui"111 else:112 pets = "conditions"113114 # images : galerie fancybox + image principale (pleine taille, dédupliquées)115 images: list[str] = []116 for a in soup.select("div.gallerie a[href], a.fancybox-thumb[href]"):117 u = a.get("href", "")118 if re.search(r"\.(?:jpg|jpeg|png|webp)$", u, re.I):119 u = _SIZE_SUFFIX.sub("", u)120 if u.startswith("/"):121 u = BASE + u122 if u.startswith("http") and not _IMG_BLACKLIST.search(u) and u not in images:123 images.append(u)124 main = soup.select_one("div.ls-single-image img[src]")125 if main:126 u = _SIZE_SUFFIX.sub("", main["src"])127 if u.startswith("http") and not _IMG_BLACKLIST.search(u) and u not in images:128 images.insert(0, u)129 if not images: # repli : toutes les images d'uploads de la page130 for u in re.findall(r'https://immeublesper\.com/wp-content/uploads/'131 r'[^"\'\s]+\.(?:jpg|jpeg|png|webp)', html, re.I):132 u = _SIZE_SUFFIX.sub("", u)133 if not _IMG_BLACKLIST.search(u) and u not in images:134 images.append(u)135136 title = ", ".join(x for x in (address or slug.replace("-", " "),137 unit_no, unit_raw) if x)138 return Listing(139 source=self.source_id,140 external_id=slug,141 url=url,142 title=title,143 address=address,144 sector=sector,145 city=infer_city(sector, default="Québec"),146 unit_type=normalize_unit_type(unit_raw),147 price=parse_price(price_label),148 price_label=price_label,149 availability=availability,150 pets=pets,151 amenities=amenities,152 images=images[:25],153 )154