SPB Git

spb/lou-ka Public

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

HTML 99.7%
8.3 KB · 199 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/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½\+?|\+|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