SPB Git

spb/lou-ka Public

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

HTML 99.7%
5.1 KB · 129 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/desroches.py : connecteur Immeubles Claude Desroches5#   (immeublesclaudedesroches.com — Granby, Cowansville, Drummondville).6#   Site PHP custom : une seule page statique /logements-a-louer/ avec un7#   tableau par ville (« Nos logements à louer à <Ville> ») ; une ligne par8#   logement, repérée par son ancre stable <a name="logNN">. Colonnes :9#   photo (badge est-loue.png = déjà loué -> ignoré), adresse, grandeur,10#   prix, étage, note libre (mois de libération, inclusions, rénové…).11#   La section « LOCAUX À LOUER » (commercial) n'a pas d'ancres logNN et12#   est donc naturellement exclue. Aucune page détail n'existe.13# -----------------------------------------------------------------------------14from __future__ import annotations1516import re1718from bs4 import BeautifulSoup1920from ..schema import Listing, normalize_unit_type, parse_price21from .base import BaseConnector2223BASE = "https://www.immeublesclaudedesroches.com"24LIST_URL = f"{BASE}/logements-a-louer/"2526_ANCHOR_RE = re.compile(r"^log\d+$")27_CITY_RE = re.compile(r"logements à louer à\s+([A-Za-zÀ-ÿ' -]+)", re.I)2829# segments de la note libre qui parlent de disponibilité (mois ou « libre ») —30# le reste (« rénové », « chauffé », « Ascenseur »…) n'est pas une date et31# fausserait la normalisation centrale32_AVAIL_SEG_RE = re.compile(33    r"\b(janvier|f[ée]vrier|mars|avril|mai|juin|juillet|ao[ûu]t|septembre|"34    r"octobre|novembre|d[ée]cembre|libre|maintenant)\b", re.I)353637def _availability_from_note(note: str) -> str:38    """Ne garde de la note que les segments de disponibilité (« juillet »,39    « libre »…) ; « rénové, juillet » -> « juillet »."""40    segs = [s.strip() for s in re.split(r"[,;]", note or "")]41    return ", ".join(s for s in segs if s and _AVAIL_SEG_RE.search(s))424344class DesrochesConnector(BaseConnector):45    source_id = "desroches"46    request_delay = 0.64748    def fetch(self) -> list[Listing]:49        html = self.get(LIST_URL).text50        soup = BeautifulSoup(html, "html.parser")5152        listings: dict[str, Listing] = {}53        for anchor in soup.find_all("a", attrs={"name": _ANCHOR_RE}):54            try:55                lst = self._parse_row(anchor)56            except Exception:57                continue58            if lst and lst.external_id not in listings:59                listings[lst.external_id] = lst60        return list(listings.values())6162    # -- ligne du tableau -----------------------------------------------------------63    def _parse_row(self, anchor) -> Listing | None:64        row = anchor.find_parent("tr")65        if row is None:66            return None6768        # badge « est loué » superposé à la photo -> logement non offert69        if row.find("img", src=re.compile(r"est-loue", re.I)):70            return None7172        # ville : plus proche en-tête « Nos logements à louer à <Ville> »73        # avant la ligne (les tableaux sont groupés par ville)74        city = ""75        for prev in anchor.find_all_previous(["p", "h1"]):76            t = " ".join(prev.get_text(" ", strip=True).split())77            m = _CITY_RE.search(t)78            if m:79                city = m.group(1).strip()80                break81        if not city:82            return None8384        cells = [" ".join(td.get_text(" ", strip=True).split())85                 for td in row.find_all("td")]86        # [photo, adresse, grandeur, prix, étage, note]87        if len(cells) < 4:88            return None89        address_raw = cells[1] if len(cells) > 1 else ""90        size_raw = cells[2] if len(cells) > 2 else ""91        price_raw = cells[3] if len(cells) > 3 else ""92        floor_raw = cells[4] if len(cells) > 4 else ""93        note_raw = cells[5] if len(cells) > 5 else ""94        if not address_raw:95            return None9697        amenities = []98        if floor_raw:99            amenities.append(f"Étage : {floor_raw}")100101        # photo réelle (default.png = image de remplacement, ignorée)102        images = []103        for img in row.find_all("img", src=True):104            src = img["src"]105            if re.search(r"est-loue|default\.png", src, re.I):106                continue107            src = re.sub(r"^(\.\./)+", "/", src)108            if not src.startswith("http"):109                src = BASE + (src if src.startswith("/") else f"/{src}")110            if src not in images:111                images.append(src)112113        ext_id = anchor.get("name")114        return Listing(115            source=self.source_id,116            external_id=ext_id,117            url=f"{LIST_URL}#{ext_id}",118            title=f"{address_raw}, {city}",119            address=f"{address_raw}, {city}",120            city=city,121            unit_type=normalize_unit_type(size_raw),122            price=parse_price(price_raw),123            price_label=price_raw,124            availability=_availability_from_note(note_raw),   # « juillet », …125            description=note_raw,           # note libre : inclusions, rénové…126            amenities=amenities,127            images=images[:5],128        )129