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.7 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/mercille.py : connecteur Groupe Mercille (groupemercille.com)5#   Quatre immeubles résidentiels à Longueuil, secteur du métro (Le D'Assigny6#   110 rue de la Barre, Le Neuville Nord/Sud, Le Sérigny). Site custom7#   (Orangium) rendu serveur : la page « Appartements résidentiels » expose8#   une table .table-listings — une ligne par unité disponible : type +9#   disponibilité (« 3.5 1er septembre »), secteur, prix, étage (« 11 de10#   22 »), immeuble (lien vers sa page) et plan PDF. Les lignes du formulaire11#   de contact (repliées sous chaque unité) sont ignorées. La page de chaque12#   immeuble (via cache BD) fournit l'adresse civique et une photo.13#   ⚠️ Le volet espaces commerciaux/bureaux du site n'est pas visité.14# -----------------------------------------------------------------------------15from __future__ import annotations1617import hashlib18import re19import time2021from bs4 import BeautifulSoup2223from ..schema import Listing, normalize_unit_type, parse_price24from .base import BaseConnector2526BASE = "https://groupemercille.com"27LIST_URL = f"{BASE}/appartements-residentiels"2829# type + disponibilité dans la 1re colonne : « 3.5 1er septembre »,30# « 4.5 Disponible maintenant »31TYPE_RE = re.compile(r"^(\d(?:\.5|½)?)\s*(.*)$")32FLOOR_RE = re.compile(r"^(\w+)\s*de\s*(\d+)$")33ADDR_RE = re.compile(34    r"\d{1,5}(?:-\d{1,5})?,?\s+(?:rue|avenue|boulevard|boul\.?|chemin|place)"35    r"\s+[^,|]{2,40},?\s*Longueuil(?:\s*\(Qu[ée]bec\))?"36    r"(?:\s*,?\s*(?:QC\s*)?[A-Z]\d[A-Z]\s?\d[A-Z]\d)?", re.I)37IMG_RE = re.compile(38    r"https://assets\.orangium\.com/api/assets/groupemercille/"39    r"[^\"\s\\']+\.(?:jpe?g|png|webp)", re.I)404142class MercilleConnector(BaseConnector):43    source_id = "mercille"44    request_delay = 0.64546    def fetch(self) -> list[Listing]:47        # Un raté transitoire de la page liste (vu 2026-08-29 ~23:20)48        # produisait un faux « 0 trouvé ok » : retry court (backoff 5/10 s),49        # puis erreur franche plutôt qu'un zéro silencieux.50        listings: list[Listing] = []51        table = None52        for attempt in range(3):53            try:54                html = self.get(LIST_URL).text55            except Exception:56                if attempt == 2:57                    raise58            else:59                soup = BeautifulSoup(html, "html.parser")60                table = soup.select_one("table.table-listings")61                if table is not None:62                    break63            if attempt < 2:64                time.sleep(5 * (attempt + 1))65        if table is None:66            raise RuntimeError(67                "table .table-listings introuvable après 3 essais")6869        seen: dict[str, int] = {}70        for tr in table.select("tbody tr"):71            try:72                tds = tr.find_all("td")73                if len(tds) < 5:74                    continue                    # ligne formulaire de contact75                cells = [td.get_text(" ", strip=True) for td in tds]76                m = TYPE_RE.match(cells[0])77                if not m:78                    continue79                unit_type = normalize_unit_type(m.group(1))80                availability = m.group(2).strip()81                sector = cells[1]82                price_label = cells[2]83                floor_label = cells[3]84                building = cells[4]85                if not re.match(r"^\d½$", unit_type) or "$" not in price_label:86                    continue8788                # lien vers la page de l'immeuble + plan PDF de l'unité89                bldg_href = ""90                pdf = ""91                for a in tr.find_all("a", href=True):92                    href = a["href"]93                    if href.lower().endswith(".pdf"):94                        pdf = href95                    elif href.startswith("/"):96                        bldg_href = href97                bslug = bldg_href.strip("/").split("/")[-1] or \98                    re.sub(r"[^a-z0-9]+", "-", building.lower()).strip("-")99100                # identifiant stable : immeuble + type + étage101                floor_key = re.sub(r"[^\w]+", "", floor_label.lower())102                base_id = f"{bslug}-{unit_type.replace('½', '.5')}-{floor_key}"103                seen[base_id] = seen.get(base_id, 0) + 1104                ext_id = base_id if seen[base_id] == 1 else \105                    f"{base_id}-{seen[base_id]}"106107                # page de l'immeuble (cache BD) : adresse civique + photo108                d = {}109                if bldg_href:110                    key = hashlib.sha1(bldg_href.encode()).hexdigest()[:16]111                    d = self.detail(f"bldg:{bslug}", key,112                                    lambda h=bldg_href: self._building(h))113114                details: dict = {}115                mfloor = FLOOR_RE.match(floor_label)116                if mfloor:117                    details["floor_label"] = f"Étage {floor_label}"118                if pdf:119                    details["plan_pdf"] = pdf120121                listings.append(Listing(122                    source=self.source_id,123                    external_id=ext_id,124                    url=f"{BASE}{bldg_href}" if bldg_href else LIST_URL,125                    title=f"{unit_type} — {building}",126                    address=d.get("address", ""),127                    sector=sector,128                    city="Longueuil",129                    unit_type=unit_type,130                    price=parse_price(price_label),131                    price_label=price_label,132                    availability=availability,133                    description=d.get("description", ""),134                    details=details,135                    images=d.get("images") or [],136                ))137            except Exception:138                continue139        return listings140141    def _building(self, href: str) -> dict:142        """Page immeuble : adresse civique (Longueuil), photo, description."""143        out: dict = {}144        try:145            html = self.get(f"{BASE}{href}").text146        except Exception:147            return out148        soup = BeautifulSoup(html, "html.parser")149        text = re.sub(r"\s+", " ", soup.get_text(" ", strip=True))150        m = ADDR_RE.search(text)151        if m:152            out["address"] = m.group(0).strip().rstrip(",")153        # description : premiers paragraphes substantiels de la page154        paras = [p.get_text(" ", strip=True) for p in soup.find_all("p")155                 if len(p.get_text(strip=True)) > 80]156        if paras:157            out["description"] = " ".join(paras)[:700]158        out["images"] = list(dict.fromkeys(IMG_RE.findall(html)))[:10]159        return out160