SPB Git

spb/lou-ka Public

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

HTML 99.7%
7.8 KB · 189 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/ascensio.py : connecteur Groupe Ascensio (groupeascensio.com)5#   Société immobilière — logements neufs à Sherbrooke (Les Nations :6#   arrondissements Jacques-Cartier et Mont-Bellevue) + Pont-Rouge (région de7#   Québec). WordPress : la page /logements-a-louer/ est rendue serveur —8#   grille `.grid-logements .grid-item` (une carte par logement disponible :9#   type + numéro, date de disponibilité, secteur, immeuble, photo, lien10#   /location/<slug>/). Les fiches (via self.detail, cache BD) ajoutent le11#   numéro de référence stable (external_id, ex. BRY-2030134), la mensualité,12#   la description, les pièces et dimensions et la galerie. Seuls les13#   logements affichés (tous « Disponible ») deviennent des annonces.14# -----------------------------------------------------------------------------15from __future__ import annotations1617import hashlib18import re1920from bs4 import BeautifulSoup2122from ..schema import Listing, normalize_unit_type, parse_price23from .base import BaseConnector2425BASE = "https://groupeascensio.com"26LIST_URL = f"{BASE}/logements-a-louer/"2728_VARIANT_IMG = re.compile(r"-\d{2,4}x\d{2,4}(?=\.(?:jpg|jpeg|png|webp)$)", re.I)293031class AscensioConnector(BaseConnector):32    source_id = "ascensio"33    request_delay = 0.634    max_details = 40      # garde-fou fiches détail (vraies requêtes)3536    def fetch(self) -> list[Listing]:37        html = self.get(LIST_URL).text38        soup = BeautifulSoup(html, "html.parser")3940        listings: dict[str, Listing] = {}41        for card in soup.select(".grid-logements .grid-item"):42            try:43                lst = self._parse_card(card)44            except Exception:45                continue46            if lst and lst.external_id not in listings:47                listings[lst.external_id] = lst4849        # fiches détail (cache BD) : référence, mensualité, description,50        # pièces/dimensions, galerie51        self._fetched = 052        out: dict[str, Listing] = {}53        for slug, lst in listings.items():54            key = hashlib.sha1(55                f"{lst.title}|{lst.availability}|{lst.sector}"56                .encode("utf-8")).hexdigest()57            try:58                payload = self.detail(slug, key,59                                      lambda u=lst.url: self._fetch_detail(u))60            except Exception:61                payload = {}62            self._apply_detail(lst, payload)63            # référence de gestion stable (BRY-2030134) quand publiée64            ref = (payload.get("reference") or "").strip()65            lst.external_id = ref or slug66            if lst.external_id not in out:67                out[lst.external_id] = lst68        return list(out.values())6970    # -- carte de la grille -----------------------------------------------------------71    def _parse_card(self, card) -> Listing | None:72        link = card.select_one("a[href*='/location/']")73        if not link:74            return None75        url = link["href"]76        m = re.search(r"/location/([^/]+)/?", url)77        if not m:78            return None79        slug = m.group(1)8081        title_el = card.select_one(".logement-title")82        title = re.sub(r"\s+", " ",83                       title_el.get_text(" ", strip=True)).strip() if title_el else slug8485        # « Disponibilité : 01/08/2026 » (bandeau survol)86        availability = ""87        extra = card.select_one(".extra-infos")88        if extra:89            m2 = re.search(r"Disponibilit[eé]\s*:?\s*([\d/]+)",90                           extra.get_text(" ", strip=True))91            if m2:92                availability = f"Disponible le {m2.group(1)}"9394        sector_el = card.select_one(".secteur-title")95        sector = ""96        if sector_el:97            sector = re.sub(r"^\s*Secteur\s*:?\s*", "",98                            sector_el.get_text(" ", strip=True)).strip(" .")99100        imm_el = card.select_one(".immeuble-value")101        immeuble = re.sub(r"\s+", " ",102                          imm_el.get_text(" ", strip=True)).strip() if imm_el else ""103104        # ville réelle : Sherbrooke, sauf mention explicite de Pont-Rouge105        city = "Pont-Rouge" if re.search(r"pont-rouge", sector, re.I) else "Sherbrooke"106107        unit_type = normalize_unit_type(title)108        if not re.fullmatch(r"\d½\+?|\+|Studio|Loft|Chambre|Maison",109                            unit_type or ""):110            unit_type = ""111112        img = card.select_one("img[src]")113        images = []114        if img and str(img.get("src", "")).startswith("http"):115            images.append(_VARIANT_IMG.sub("", img["src"]))116117        amenities = [f"Immeuble : {immeuble}"] if immeuble else []118        return Listing(119            source=self.source_id,120            external_id=slug,          # remplacé par la référence en aval121            url=url,122            title=f"{title}{immeuble}" if immeuble else title,123            address=immeuble if re.match(r"^\d", immeuble) else "",124            sector=sector,125            city=city,126            unit_type=unit_type,127            availability=availability,128            amenities=amenities,129            images=images,130        )131132    # -- fiche logement ---------------------------------------------------------------133    def _fetch_detail(self, url: str) -> dict:134        if self._fetched >= self.max_details:135            raise RuntimeError("budget de fiches détail atteint")136        self._fetched += 1137        html = self.get(url).text138        soup = BeautifulSoup(html, "html.parser")139        out: dict = {}140141        # paires h5 -> valeur : Référence, Mensualité, Disponibilités142        for h in soup.select("h5"):143            lab = h.get_text(" ", strip=True).lower()144            sib = h.find_next_sibling()145            if sib is None:146                continue147            val = re.sub(r"\s+", " ", sib.get_text(" ", strip=True)).strip()148            if "référence" in lab or "reference" in lab:149                out["reference"] = val150            elif "mensualité" in lab or "mensualite" in lab:151                out["price_label"] = val152            elif "disponibilités" in lab or "disponibilites" in lab:153                out["availability"] = val154            elif "pièces et dimensions" in lab and sib.name == "ul":155                out["rooms"] = [re.sub(r"\s+", " ", li.get_text(" ", strip=True))156                                for li in sib.select("li")][:15]157158        # description : paragraphes longs du corps de la fiche159        paras = [re.sub(r"\s+", " ", p.get_text(" ", strip=True))160                 for p in soup.select("p")]161        longs = [p for p in paras if len(p) > 120]162        if longs:163            out["description"] = " ".join(longs)[:1500]164165        images: list[str] = []166        for img in soup.select("img[src*='/wp-content/uploads/']"):167            src = _VARIANT_IMG.sub("", str(img.get("src") or ""))168            if src.startswith("http") and src not in images \169                    and not re.search(r"logo|icon|favicon", src, re.I):170                images.append(src)171        out["images"] = images[:20]172        return out173174    def _apply_detail(self, lst: Listing, d: dict) -> None:175        if not d:176            return177        if d.get("price_label"):178            lst.price_label = d["price_label"]           # « 1425 $ / mois »179            lst.price = parse_price(re.sub(r"(\d)\s(\d{3})", r"\1\2",180                                           d["price_label"]))181        if d.get("availability"):182            lst.availability = d["availability"]183        if d.get("description"):184            lst.description = d["description"]185        if d.get("rooms"):186            lst.amenities = list(dict.fromkeys(lst.amenities + d["rooms"]))187        if d.get("images"):188            lst.images = list(dict.fromkeys(d["images"] + lst.images))[:20]189