SPB Git

spb/lou-ka Public

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

HTML 99.7%
10.1 KB · 246 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/desmarais.py : connecteur Immeubles Desmarais (immeublesdesmarais.ca)5#   Gestionnaire historique de Gatineau (Hull, Aylmer, Buckingham). Site PHP6#   custom encodé ISO-8859-1, rendu CÔTÉ SERVEUR : la page /logements liste7#   les cartes (titre, secteur, chambres, superficie, type, prix « À partir8#   de ») avec pagination ?entity=logements&page=N. Le script de la carte9#   Google (showAddress) publie en plus l'adresse civique + code postal et le10#   lien canonique /logements/<id>/<slug> de chaque annonce.11#   La fiche détail ajoute la date de disponibilité, la description complète12#   et la galerie (/upload/logements/<id>/NN.jpg) — via self.detail() (cache13#   BD) avec plafond par sync. Les locaux commerciaux vivent sous /locaux14#   (entité distincte) et ne sont donc jamais ramassés.15# -----------------------------------------------------------------------------16from __future__ import annotations1718import hashlib19import re2021from bs4 import BeautifulSoup2223from ..schema import Listing, normalize_unit_type, parse_area_sqft, parse_price24from .base import BaseConnector2526BASE = "https://www.immeublesdesmarais.ca"27LIST_URL = f"{BASE}/logements"2829# secteurs de l'agglomération de Gatineau (le site filtre par ces 4 secteurs)30_SECTEURS = {"hull": "Hull", "aylmer": "Aylmer", "buckingham": "Buckingham",31             "gatineau": ""}3233# showAddress(map, '<adresse civique>', '...href=\"/logements/<id>/<slug>\"...')34_MAP_RE = re.compile(35    r"showAddress\(map,\s*'([^']*)',\s*'.*?href=\\\"/logements/(\d+)/([^/\\\"]+)"36)373839def _clean_txt(s: str) -> str:40    return re.sub(r"\s+", " ", (s or "").strip())414243class DesmaraisConnector(BaseConnector):44    source_id = "desmarais"45    request_delay = 1.046    max_pages = 6        # garde-fou de pagination (12 annonces = 2 pages)47    max_details = 20     # fiches détail réellement visitées par sync48    max_images = 204950    def __init__(self) -> None:51        super().__init__()52        self._detail_calls = 05354    # -- helpers ---------------------------------------------------------------55    def _html(self, url: str) -> str:56        """Le site est encodé ISO-8859-1 (meta charset) : forcer le décodage,57        sinon les accents deviennent mojibake selon l'en-tête du serveur."""58        resp = self.get(url)59        resp.encoding = "iso-8859-1"60        return resp.text6162    @staticmethod63    def _city_sector(raw: str) -> tuple[str, str]:64        """« Hull (Québec) J8X 4G9 » -> ('Gatineau', 'Hull') ;65        « Gatineau (Québec) » -> ('Gatineau', '')."""66        base = re.split(r"[(]", _clean_txt(raw))[0].strip()67        key = base.lower()68        if key in _SECTEURS:69            return "Gatineau", _SECTEURS[key]70        return base, ""    # imprévu : on garde le libellé source tel quel7172    @staticmethod73    def _unit_type(beds: str) -> str:74        """Nombre de chambres de la carte : « bach » (garçonnière) -> Studio,75        sinon « N chambres » normalisé par la couche commune."""76        b = _clean_txt(beds).lower()77        if b.startswith("bach") or "garconni" in b or "garçonni" in b:78            return "Studio"79        m = re.match(r"^(\d+)", b)80        return normalize_unit_type(f"{m.group(1)} chambres") if m else ""8182    # -- fiche détail ------------------------------------------------------------83    def _fetch_detail(self, url: str) -> dict:84        """Date de disponibilité, description complète et galerie photos."""85        self._detail_calls += 186        html = self._html(url)87        soup = BeautifulSoup(html, "html.parser")88        out: dict = {}8990        td = soup.find("td", string=re.compile(r"Date de disponibilit"))91        if td:92            nxt = td.find_next_sibling("td")93            if nxt:94                out["availability"] = _clean_txt(nxt.get_text(" ", strip=True))9596        desc = soup.select_one("#detailRight p.greyText")97        if desc:98            txt = desc.get_text("\n", strip=True)99            out["description"] = re.sub(r"\s*\n\s*", " ", txt).strip()[:2000]100101        images: list[str] = []102        for a in soup.select("a.glightbox[href^='/upload/']"):103            u = BASE + a["href"]104            if u not in images:105                images.append(u)106        out["images"] = images107        return out108109    # -- fetch -----------------------------------------------------------------110    def fetch(self) -> list[Listing]:111        listings: dict[str, Listing] = {}112        canon: dict[str, tuple[str, str]] = {}   # id -> (adresse civique, slug)113114        for page in range(1, self.max_pages + 1):115            url = LIST_URL if page == 1 else f"{LIST_URL}?entity=logements&page={page}"116            try:117                html = self._html(url)118            except Exception:119                break120            # adresses civiques + liens canoniques publiés par la carte Google121            for addr, num, slug in _MAP_RE.findall(html):122                canon.setdefault(num, (_clean_txt(addr), slug))123            soup = BeautifulSoup(html, "html.parser")124            cards = soup.select("div.infoLogement")125            if not cards:126                break127            before = len(listings)128            for card in cards:129                try:130                    self._parse_card(card, listings)131                except Exception:132                    continue133            if len(listings) == before:   # page sans nouveauté = fin134                break135136        # adresse civique (carte Google) + fiche détail (cache BD)137        for ext, lst in listings.items():138            addr, slug = canon.get(ext, ("", ""))139            if addr:140                # « 215 Rue de Canadel Gatineau, Québec J8T 8C3, J8T 8C3 » ->141                # partie civique seulement (la ville est déjà normalisée)142                civic = addr.split(",")[0].strip()143                civic = re.sub(r"\s+(Gatineau|Hull|Aylmer|Buckingham)$", "",144                               civic, flags=re.I)145                lst.address = f"{civic}, {lst.city}" if civic else ""146            if slug:147                lst.url = f"{BASE}/logements/{ext}/{slug}"148149            key = hashlib.sha1(150                f"{lst.title}|{lst.price_label}|{lst.description}"151                .encode("utf-8")).hexdigest()[:20]152            if self._detail_calls >= self.max_details:153                continue154            try:155                payload = self.detail(ext, key,156                                      lambda u=lst.url: self._fetch_detail(u))157            except Exception:158                continue159            if payload.get("availability"):160                lst.availability = payload["availability"]161            if payload.get("description"):162                lst.description = payload["description"]163            for img in payload.get("images") or []:164                if img not in lst.images and len(lst.images) < self.max_images:165                    lst.images.append(img)166167        return list(listings.values())168169    # -- carte de la liste -------------------------------------------------------170    def _parse_card(self, card, listings: dict[str, Listing]) -> None:171        link = card.select_one("a[href*='details.php']")172        if not link:173            return174        m = re.search(r"[?&]id=(\d+)", link.get("href", ""))175        if not m:176            return177        ext = m.group(1)178        if ext in listings:179            return180181        top = card.select_one(".logementImageTop")182        spans = top.select("span") if top else []183        title = _clean_txt(spans[0].get_text(" ", strip=True)) if spans else ""184        loc_raw = _clean_txt(spans[1].get_text(" ", strip=True)) if len(spans) > 1 else ""185        city, sector = self._city_sector(loc_raw)186187        # type d'immeuble (Appartement / Condo / Maison) + prix « À partir de »188        prix_sec = card.select_one(".logementPrixSection")189        btype, price_label = "", ""190        if prix_sec:191            st = prix_sec.select_one("span strong")192            btype = _clean_txt(st.get_text(strip=True)) if st else ""193            pr = prix_sec.select_one("span.pull-right")194            price_label = _clean_txt(pr.get_text(" ", strip=True)) if pr else ""195        if re.search(r"commercial|local", btype, re.I):196            return   # résidentiel seulement197198        # chambres + superficie (blocs à icônes de la carte)199        beds = sqft = ""200        for div in card.select(".logementImageBot strong, .logementImageBotInfoBox strong"):201            t = _clean_txt(div.get_text(strip=True))202            if "pi²" in t or "pi2" in t.lower():203                sqft = sqft or t204            elif t:205                beds = beds or t206        if not beds:   # repli : blocs « <strong>2</strong> chambres »207            m2 = re.search(r"<strong>\s*(bach|\d+)\s*</strong>\s*chambre",208                           str(card), re.I)209            if m2:210                beds = m2.group(1)211            m3 = re.search(r"<strong>\s*([\d\s]+pi²)\s*</strong>", str(card))212            if m3 and not sqft:213                sqft = _clean_txt(m3.group(1))214215        # extrait de description de la carte (remplacé par la fiche détail)216        snippet_el = card.select_one(".contentLogementInt")217        snippet = _clean_txt(snippet_el.get_text(" ", strip=True)) if snippet_el else ""218        snippet = re.sub(r"\(\.\.\.\)$", "…", snippet)219220        img_el = card.select_one(".logementImage[style]")221        images: list[str] = []222        if img_el:223            m4 = re.search(r"url\(([^)]+)\)", img_el.get("style", ""))224            if m4:225                images.append(BASE + m4.group(1).strip("'\" "))226227        details: dict = {}228        if btype:229            details["building_type"] = btype230231        listings[ext] = Listing(232            source=self.source_id,233            external_id=ext,234            url=f"{BASE}/logements/{ext}/",235            title=title,236            sector=sector,237            city=city,238            unit_type=self._unit_type(beds),239            price=parse_price(price_label),240            price_label=price_label,241            area_sqft=parse_area_sqft(sqft),242            description=snippet,243            details=details,244            images=images,245        )246