SPB Git forge

spb/lou-ka

Public

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

232commits 1branches 0releases
172.9 MBsize
maindefault branch
2 days agolast push
HTML 98.9% Python 0.6%
6.4 KB · 159 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/logispro_mauricie.py : connecteur LogisPro Mauricie (logispro.ca)5#   Trois-Rivières, Cap-de-la-Madeleine, Shawinigan, Bécancour, Nicolet.6#   ⚠️ Compagnie DISTINCTE de LogisPro Rimouski (logisprorimouski.com,7#   connecteur `logispro`). WordPress thème maison rendu serveur :8#   /appartements-a-louer/ = cartes (prix, adresse, typologie, lien9#   /apartments/<slug>/) ; page détail = liste « Secteur / Prix / Grandeur /10#   Étage / … », description riche et galerie fancybox.11#   Granularité : une annonce par logement disponible.12# -----------------------------------------------------------------------------13from __future__ import annotations1415import hashlib16import re1718from bs4 import BeautifulSoup1920from ..schema import Listing, normalize_unit_type21from .base import BaseConnector2223BASE = "https://logispro.ca"24LIST_URL = f"{BASE}/appartements-a-louer/"2526PRICE_RE = re.compile(r"([\d\s ,]+)\s*\$")27# villes desservies, pour extraire la ville depuis l'adresse de la carte28CITY_RE = re.compile(29    r"(Trois-Rivi[èe]res|Shawinigan|B[ée]cancour|Nicolet|"30    r"Cap-de-la-Madeleine|Gentilly|Saint-Maurice)", re.I)313233class LogisProMauricieConnector(BaseConnector):34    source_id = "logispro_mauricie"35    request_delay = 0.73637    def fetch(self) -> list[Listing]:38        listings: list[Listing] = []39        try:40            html = self.get(LIST_URL).text41        except Exception:42            return listings43        soup = BeautifulSoup(html, "html.parser")4445        seen: set[str] = set()46        for card in soup.select("div.apartments-item"):47            try:48                a = card.select_one('a[href*="/apartments/"]')49                if a is None:50                    continue51                url = a.get("href", "").split("?")[0]52                slug = url.rstrip("/").split("/")[-1]53                if not slug or slug in seen:54                    continue55                seen.add(slug)5657                price = None58                price_label = ""59                price_el = card.select_one("p.price")60                if price_el is not None:61                    price_label = re.sub(r"\s+", " ",62                                         price_el.get_text(" ", strip=True))63                    m = PRICE_RE.search(price_label)64                    if m:65                        try:66                            price = float(m.group(1).replace(" ", "")67                                          .replace(" ", "").replace(",", ""))68                        except ValueError:69                            pass7071                addr_el = card.select_one("p.adress, p.address")72                address = (re.sub(r"\s+", " ", addr_el.get_text(" ", strip=True))73                           if addr_el else "")74                room_el = card.select_one("p.room")75                unit_type = normalize_unit_type(76                    room_el.get_text(" ", strip=True) if room_el else "")7778                city = ""79                m = CITY_RE.search(address)80                if m:81                    city = m.group(1)82                    if city.lower() in ("cap-de-la-madeleine", "gentilly"):83                        city = ("Trois-Rivières"84                                if "cap" in m.group(1).lower() else "Bécancour")8586                card_key = hashlib.sha1(87                    f"{price_label}|{address}|{unit_type}"88                    .encode("utf-8")).hexdigest()89                d = self.detail(slug, card_key,90                                lambda url=url: self._fetch_detail(url))9192                listings.append(Listing(93                    source=self.source_id,94                    external_id=slug,95                    url=url,96                    title=address or slug,97                    address=d.get("address") or address,98                    sector=d.get("sector", ""),99                    city=city or d.get("sector", ""),100                    unit_type=unit_type or d.get("unit_type", ""),101                    price=price,102                    price_label=price_label,103                    availability=d.get("availability", ""),104                    description=d.get("description", ""),105                    amenities=d.get("amenities") or [],106                    images=d.get("images") or [],107                ))108            except Exception:109                continue110        return listings111112    def _fetch_detail(self, url: str) -> dict:113        """Page détail : liste top_descript (Secteur/Prix/Grandeur/Étage/…),114        description riche (dispo « dès maintenant » incluse) et galerie."""115        out: dict = {}116        try:117            html = self.get(url).text118        except Exception:119            return out120        soup = BeautifulSoup(html, "html.parser")121122        amenities: list[str] = []123        for li in soup.select("ul.top_descript li"):124            txt = re.sub(r"\s+", " ", li.get_text(" ", strip=True))125            if not txt or ":" not in txt:126                continue127            label, _, value = txt.partition(":")128            label, value = label.strip(), value.strip()129            if not value:130                continue131            low = label.lower()132            if low == "secteur":133                out["sector"] = value134            elif low == "prix":135                pass  # déjà sur la carte136            elif low == "grandeur":137                out["unit_type"] = normalize_unit_type(value)138            else:139                amenities.append(f"{label} : {value}")140        out["amenities"] = amenities[:20]141142        desc_el = soup.select_one("div.description")143        if desc_el is not None:144            desc = desc_el.get_text("\n", strip=True)145            out["description"] = re.sub(r"\n{2,}", "\n", desc).strip()[:1200]146            m = re.search(r"Disponible[^\n•]*", desc, re.I)147            if m:148                out["availability"] = m.group(0).strip()149            # adresse complète avec code postal (dernier paragraphe)150            m = re.search(r"\d[\w#\-]*[^\n]*,\s*(?:QC|Qu[ée]bec)[^\n]*", desc)151            if m:152                out["address"] = m.group(0).replace(", Canada", "").strip()153154        out["images"] = [155            a.get("href", "").strip()156            for a in soup.select(".apartment__gallery a.fancybox[href]")157            if a.get("href", "").strip()][:25]158        return out159