SPB Git

spb/lou-ka Public

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

HTML 99.7%
9.5 KB · 230 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/gestion_habitation.py : connecteur Gestion Habitation5#   (gestionhabitation.ca — Abitibi-Témiscamingue : Val-d'Or, Amos, Malartic ;6#   seul gestionnaire structuré couvrant Amos). WordPress + Oxygen Builder +7#   WP Grid Builder, tout rendu serveur. Liste /a-louer/ : cartes8#   `.location-card` (adresse, n° de porte, ville, secteur, disponibilité9#   « DISPONIBLE » / « juin 2026 » / « NON DISPONIBLE », prix « 900$/mois ») —10#   les cartes « NON DISPONIBLE » (logements loués) sont exclues. Fiche détail11#   /logement/<slug>/ (via cache BD) : bloc structuré `.location-data__item`12#   (pièces, chambres, salles de bain, chauffage/électricité/internet,13#   animaux, fumeur, stationnement, disponibilité), description14#   (`.location-more-infos`) et galerie (carrousel Oxygen, liens pleine taille).15#   robots.txt WP standard (Disallow /wp-admin/), sitemap XML.16# -----------------------------------------------------------------------------17from __future__ import annotations1819import hashlib20import re2122from bs4 import BeautifulSoup2324from ..schema import Listing, normalize_unit_type, strip_accents25from .base import BaseConnector2627BASE = "https://gestionhabitation.ca"28LIST_URL = f"{BASE}/a-louer/"2930# suffixe de redimensionnement WordPress (« -224x300.jpg » -> pleine taille)31_SIZE_SUFFIX = re.compile(r"-\d{2,4}x\d{2,4}(?=\.(?:jpg|jpeg|png|webp)$)", re.I)32# « 3 1/2 », « 3½ », « 4 et demi » dans le titre de la carte33_TYPE_RE = re.compile(r"\b(\d)\s*(?:1/2|½|et\s+demi)", re.I)343536def _clean_price_label(label: str) -> str:37    """'1,250$/mois' -> '1250$/mois' compatible parse_price (virgule = milliers)."""38    return re.sub(r"(\d),(\d{3})", r"\1\2", label)394041def _oui_non(raw: str) -> str | None:42    """'Oui'/'Non' du bloc structuré -> 'oui'/'non', sinon None (inconnu)."""43    k = strip_accents((raw or "").strip().lower())44    if k.startswith("oui"):45        return "oui"46    if k.startswith("non"):47        return "non"48    return None495051class GestionHabitationConnector(BaseConnector):52    source_id = "gestion_habitation"53    request_delay = 0.654    max_details = 30     # garde-fou fiches détail (vraies requêtes par sync)5556    def fetch(self) -> list[Listing]:57        html = self.get(LIST_URL).text58        soup = BeautifulSoup(html, "html.parser")5960        self._fetched = 061        listings: dict[str, Listing] = {}62        for card in soup.select(".location-card"):63            try:64                self._parse_card(card, listings)65            except Exception:66                continue67        return list(listings.values())6869    # -- carte (.location-card, grille WP Grid Builder rendue serveur) -----------------70    def _parse_card(self, card, listings: dict[str, Listing]) -> None:71        link = card.select_one("a.location-card__permalink[href]")72        if not link:73            return74        url = link["href"]75        m = re.search(r"/logement/([^/?#]+)", url)76        if not m:77            return78        ext_id = m.group(1).strip("/")79        if not ext_id or ext_id in listings:80            return8182        # disponibilité : « DISPONIBLE », « juin 2026 »… — on saute les loués83        avail_el = card.select_one(".location-card__availability")84        availability = avail_el.get_text(" ", strip=True) if avail_el else ""85        if re.search(r"non\s+disponible", availability, re.I):86            return8788        # titre = adresse (ou libellé libre) + n° de porte éventuel89        title_el = link.select_one(".location-card__address .ct-span")90        title = title_el.get_text(strip=True) if title_el else ""91        door_el = link.select_one(".location-card__door .ct-span")92        door = door_el.get_text(strip=True) if door_el else ""9394        # 2e bloc : ville (Val-d'Or, Amos, Malartic) et secteur (Centre-ville…)95        city = sector = ""96        meta_blocks = card.select(".location-card__data-title")97        if len(meta_blocks) > 1:98            spans = meta_blocks[1].select(".ct-span")99            if spans:100                city = spans[0].get_text(strip=True)101            if len(spans) > 1:102                sector = spans[1].get_text(strip=True)103104        # adresse civique seulement si le libellé en est une (commence par un n°)105        address = ""106        if re.match(r"^\d+[\s,]", title):107            address = title + (f", app. {door}" if door else "")108            if city:109                address += f", {city}"110111        price_el = card.select_one(".location-card__price")112        period_el = card.select_one(".location-card__period")113        price_label = ""114        if price_el and price_el.get_text(strip=True):115            price_label = price_el.get_text(strip=True) + \116                (period_el.get_text(strip=True) if period_el else "")117118        img_el = card.select_one("img.location-card__image[src]")119        images = [_SIZE_SUFFIX.sub("", img_el["src"])] if img_el else []120121        # type d'unité : « 3 1/2 » du libellé de la carte (sinon fiche détail)122        unit_type = ""123        m_type = _TYPE_RE.search(title)124        if m_type:125            unit_type = normalize_unit_type(f"{m_type.group(1)} 1/2")126127        lst = Listing(128            source=self.source_id,129            external_id=ext_id,130            url=url,131            title=title + (f" #{door}" if door else ""),132            address=address,133            sector=sector,134            city=city,135            unit_type=unit_type,136            price_label=_clean_price_label(price_label),137            availability=availability,138            images=images,139        )140141        key = hashlib.sha1(142            f"{title}|{price_label}|{availability}|{city}"143            .encode("utf-8")).hexdigest()144        try:145            payload = self.detail(ext_id, key,146                                  lambda u=url: self._fetch_detail(u))147            self._apply_detail(lst, payload)148        except Exception:149            pass150        listings[ext_id] = lst151152    # -- fiche détail (/logement/<slug>/) ----------------------------------------------153    def _fetch_detail(self, url: str) -> dict:154        """Bloc structuré (pièces, animaux…), description et galerie complète."""155        if self._fetched >= self.max_details:156            raise RuntimeError("budget de fiches détail atteint")157        self._fetched += 1158        html = self.get(url).text159        soup = BeautifulSoup(html, "html.parser")160        out: dict = {}161162        # paires titre/valeur du bloc .location-data (rendu serveur)163        fields: dict[str, str] = {}164        for it in soup.select(".location-data__item"):165            t_el = it.select_one(".location-data__item-title")166            v_el = it.select_one(".location-data__item-value")167            if not t_el:168                continue169            key = strip_accents(t_el.get_text(strip=True).rstrip(" :").lower())170            val = v_el.get_text(" ", strip=True) if v_el else ""171            if val:172                fields[key] = val173        out["fields"] = fields174175        desc_el = soup.select_one(".location-more-infos")176        if desc_el:177            txt = desc_el.get_text("\n", strip=True)178            txt = re.sub(r"^Sp[ée]cifications\n", "", txt)179            txt = re.sub(r"^Inclusions\n", "", txt)180            out["description"] = re.sub(r"[ \t]+", " ", txt).strip()[:1500]181182        images: list[str] = []183        for a in soup.select("a.oxy-carousel-builder_gallery-image[href]"):184            u = a["href"].strip()185            if u.startswith("http") and u not in images:186                images.append(u)187        out["images"] = images[:25]188        return out189190    def _apply_detail(self, lst: Listing, d: dict) -> None:191        """Reporte le payload (frais ou en cache) sur l'annonce."""192        if not d:193            return194        if d.get("description"):195            lst.description = d["description"]196        if d.get("images") and len(d["images"]) > len(lst.images):197            lst.images = d["images"]198199        fields = d.get("fields") or {}200        amenities: list[str] = []201        pieces = fields.get("nombre de pieces", "")202        if pieces.isdigit():203            amenities.append(f"{pieces} pièce(s)")204            # le site affiche « n½ » pour n pièces : dérivation fidèle205            if not lst.unit_type:206                lst.unit_type = normalize_unit_type(f"{pieces} 1/2")207        if fields.get("chambre(s)"):208            amenities.append(f"{fields['chambre(s)']} chambre(s)")209        if fields.get("salle de bain(s)"):210            amenities.append(f"{fields['salle de bain(s)']} salle(s) de bain")211        for label, key in (("Chauffage", "chauffage"),212                           ("Électricité", "electricite"),213                           ("Internet", "internet"),214                           ("Stationnement", "stationnement")):215            if fields.get(key):216                amenities.append(f"{label} : {fields[key]}")217        if fields.get("fumeur"):218            amenities.append(f"Fumeur : {fields['fumeur']}")219        lst.amenities = list(dict.fromkeys(lst.amenities + amenities))220221        lst.pets = _oui_non(fields.get("animaux", ""))222        if not lst.availability and fields.get("disponibilite"):223            lst.availability = fields["disponibilite"]224        if not lst.price_label and fields.get("prix"):225            lst.price_label = _clean_price_label(fields["prix"])226        if not lst.city and fields.get("ville"):227            lst.city = fields["ville"]228        if not lst.sector and fields.get("secteur"):229            lst.sector = fields["secteur"]230