SPB Git

spb/lou-ka Public

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

HTML 99.7%
6.2 KB · 160 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/acceslogis_gb.py : connecteur Accès Logis GB (acceslogisgb.com)5#   Gestionnaire de Lanaudière/Mauricie (Joliette, Ste-Élisabeth,6#   St-Ambroise-de-Kildare, Shawinigan…). Site builder mono-page : les cartes7#   « LOGEMENTS DISPONIBLES » vivent dans des grilles .columnswithgap-028#   (titre <p.font-026><b>, photo, description <p.font-014> qui se termine par9#   « Disponible … » + « 1150$ PAR MOIS »). Aucune page détail ni URL par10#   annonce : external_id = slug du titre + repère d'étage tiré de la11#   description (les titres se répètent d'un étage à l'autre). 1 requête/sync.12# -----------------------------------------------------------------------------13from __future__ import annotations1415import hashlib16import re17import unicodedata1819from bs4 import BeautifulSoup2021from ..schema import Listing, normalize_unit_type, parse_price22from .base import BaseConnector2324BASE = "https://acceslogisgb.com"25LIST_URL = f"{BASE}/"2627# villes desservies (clé sans accents, en minuscules -> nom canonique)28_CITIES = [29    ("ste-elisabeth", "Sainte-Élisabeth"),30    ("sainte-elisabeth", "Sainte-Élisabeth"),31    ("sainte-elizabeth", "Sainte-Élisabeth"),32    ("st-ambroise", "Saint-Ambroise-de-Kildare"),33    ("saint-ambroise", "Saint-Ambroise-de-Kildare"),34    ("shawinigan", "Shawinigan"),35    ("joliette", "Joliette"),36    ("crabtree", "Crabtree"),37    ("berthier", "Berthierville"),38    ("st-thomas", "Saint-Thomas"),39    ("saint-thomas", "Saint-Thomas"),40    ("st-come", "Saint-Côme"),41    ("saint-come", "Saint-Côme"),42]4344# repère d'étage dans la description (« en demi sous-sol », « au 2e étage »…)45_FLOOR_RE = re.compile(46    r"(demi[- ]sous[- ]sol|sous[- ]sol|rez[- ]de[- ]chauss[ée]e|\d+\s*(?:er|e|ème|ieme)\s*étage)",47    re.I)484950def _strip_accents(s: str) -> str:51    return "".join(c for c in unicodedata.normalize("NFD", s)52                   if unicodedata.category(c) != "Mn")535455def _slug(s: str) -> str:56    s = _strip_accents(s.lower())57    s = re.sub(r"[^a-z0-9]+", "-", s)58    return s.strip("-")596061class AccesLogisGBConnector(BaseConnector):62    source_id = "acceslogis_gb"63    request_delay = 0.76465    def fetch(self) -> list[Listing]:66        resp = self.get(LIST_URL)67        resp.encoding = "utf-8"       # le serveur ne déclare pas le charset68        html = resp.text69        soup = BeautifulSoup(html, "html.parser")70        listings: dict[str, Listing] = {}71        for grid in soup.select("div.columnswithgap-02"):72            for col in grid.find_all("div", recursive=False):73                try:74                    self._parse_card(col, listings)75                except Exception:76                    continue77        return list(listings.values())7879    def _parse_card(self, col, listings: dict[str, Listing]) -> None:80        title_el = col.select_one("p.font-026 b")81        if not title_el:82            return83        title = re.sub(r"\s+", " ", title_el.get_text(" ", strip=True))84        # hors périmètre logement : mini-entrepôts, locaux85        if re.search(r"entrep[oô]t|commercial|local\b", title, re.I):86            return8788        desc_el = col.select_one("p.font-014")89        availability, price_label = "", ""90        description = ""91        if desc_el:92            # les mentions « Disponible … » et « 1150$ » sont des <span> en fin93            # de paragraphe : on les extrait puis on garde le reste en description94            for span in desc_el.find_all("span"):95                t = re.sub(r"\s+", " ", span.get_text(" ", strip=True))96                if re.match(r"(?i)disponible|libre", t):97                    availability = t98                elif re.search(r"\d\s*\$", t):99                    price_label = t100                span.extract()101            description = re.sub(r"\s+", " ", desc_el.get_text(" ", strip=True))102            description = re.sub(r"\bPAR MOIS\b\s*$", "", description).strip()103104        # ville : depuis le titre, sinon la description105        low = _strip_accents(f"{title} {description}".lower())106        city = ""107        for key, name in _CITIES:108            if key in low:109                city = name110                break111112        # type d'unité : titre (« 3 1/2, … ») sinon description113        unit_type = normalize_unit_type(title)114        if not re.fullmatch(r"\d½|\+|Studio|Loft|Chambre|Maison|Condo",115                            unit_type or ""):116            unit_type = normalize_unit_type(description)117            if not re.fullmatch(r"\d½|\+|Studio|Loft|Chambre|Maison|Condo",118                                unit_type or ""):119                unit_type = ""120121        # adresse civique si mentionnée (« situé au 2510 Rang du Ruisseau à … »)122        address = ""123        m = re.search(r"situ[ée]e?\s+au\s+([\d][^.,]*?)\s+à\s", description)124        if m:125            address = m.group(1).strip()126        elif re.match(r"\d+\s+\w", title) and not normalize_unit_type(title).endswith("½"):127            address = title.split(",")[0].strip()   # le titre est une adresse civique128129        # external_id stable : slug du titre + repère d'étage (les titres se130        # répètent entre étages d'un même immeuble)131        ext = _slug(title)132        m_fl = _FLOOR_RE.search(description)133        if m_fl:134            ext += "-" + _slug(m_fl.group(1))135        if ext in listings:                    # ultime repli : hash du texte136            ext += "-" + hashlib.sha1(description.encode("utf-8")).hexdigest()[:6]137138        images: list[str] = []139        img = col.select_one("img[src]")140        if img:141            src = img["src"]142            if not src.startswith("http"):143                src = f"{BASE}/{src.lstrip('/')}"144            images.append(src)145146        listings[ext] = Listing(147            source=self.source_id,148            external_id=ext,149            url=f"{LIST_URL}#logements",150            title=title,151            address=address,152            city=city,153            unit_type=unit_type,154            price=parse_price(price_label),155            price_label=price_label,156            availability=availability,157            description=description[:900],158            images=images,159        )160