SPB Git

spb/lou-ka Public

Lou·Ka — tous les logements à louer du Québec, un seul endroit.

HTML 99.7%
6.3 KB · 154 lines python
Raw Blame History
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