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%
5.7 KB · 138 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/carre_legendes.py : connecteur Carré des Légendes5#   (carredeslegendes.ca — Saint-Jean-sur-Richelieu, boul. Saint-Luc).6#   Condos locatifs haut de gamme (~7 bâtiments). WordPress rendu serveur :7#   la page /habitations/ embarque une carte SVG interactive (bâtiments →8#   niveaux → unités) dont TOUTES les données sont dans le HTML statique :9#   tooltip « disp / indisp » (Disponible, Réservée, Déjà louée) + bloc10#   « content level4 » (bâtiment, no d'unité, type | superficie, inclusions,11#   photos, plan PDF). Granularité : unité. Prix non publiés.12# -----------------------------------------------------------------------------13from __future__ import annotations1415import re1617from bs4 import BeautifulSoup1819from ..schema import Listing, normalize_unit_type20from .base import BaseConnector2122BASE = "https://carredeslegendes.ca"23PAGE_URL = f"{BASE}/habitations/"2425CITY = "Saint-Jean-sur-Richelieu"2627# « 4½ | 1240 pi. ca. »28TYPE_SQFT_RE = re.compile(r"([\dS][\d ½/]*)\s*\|\s*([\d\s]+)\s*pi", re.I)29BATIMENT_RE = re.compile(r"B[âa]timent\s*#?\s*(\d+)", re.I)303132class CarreLegendesConnector(BaseConnector):33    source_id = "carre_legendes"34    request_delay = 0.63536    def fetch(self) -> list[Listing]:37        listings: list[Listing] = []38        try:39            html = self.get(PAGE_URL).text40        except Exception:41            return listings42        soup = BeautifulSoup(html, "html.parser")4344        # 1) statut par unité : tooltips « ttip_<id> » (disp = encore offerte)45        status: dict[str, str] = {}46        for tip in soup.select("div.tooltip"):47            tid = (tip.get("id") or "").replace("ttip_", "")48            spans = [s.get_text(" ", strip=True) for s in tip.find_all("span")]49            if not tid or not spans or not spans[0].startswith("Unité"):50                continue51            classes = tip.get("class") or []52            if "disp" not in classes:53                continue          # « indisp » = déjà louée54            status[tid] = spans[1] if len(spans) > 1 else "Disponible"5556        # 2) fiches d'unités : blocs « content level4 » (content_<id>)57        for block in soup.select("div.content.level4"):58            bid = (block.get("id") or "").replace("content_", "")59            if bid not in status:60                continue61            try:62                infos = block.select_one("div.bottom_infos")63                if not infos:64                    continue65                h2 = infos.find("h2")66                unit_no = ""67                if h2:68                    m = re.search(r"(\d+)", h2.get_text(" ", strip=True))69                    if m:70                        unit_no = m.group(1)71                if not unit_no:72                    continue73                batiment = ""74                st = infos.select_one("p.sous-titre")75                if st:76                    m = BATIMENT_RE.search(st.get_text(" ", strip=True))77                    if m:78                        batiment = m.group(1)7980                unit_type, sqft = "", None81                h3 = block.find("h3")82                if h3:83                    m = TYPE_SQFT_RE.search(h3.get_text(" ", strip=True))84                    if m:85                        unit_type = normalize_unit_type(m.group(1))86                        try:87                            sqft = float(m.group(2).replace(" ", "")88                                         .replace(" ", ""))89                        except ValueError:90                            sqft = None9192                # inclusions de l'immeuble (paragraphe centré de la fiche)93                amenities: list[str] = []94                for p in infos.find_all("p"):95                    if "sous-titre" in (p.get("class") or []):96                        continue97                    txt = p.get_text("\n", strip=True)98                    amenities += [a.strip() for a in txt.split("\n")99                                  if 3 < len(a.strip()) < 80]100101                images = []102                for img in block.select("div.gallery img"):103                    src = img.get("src") or ""104                    if src.startswith("http"):105                        images.append(re.sub(r"-\d+x\d+(\.\w+)$", r"\1", src))106                plan = block.select_one("a.planpdf")107                details: dict = {}108                if plan and plan.get("href"):109                    details["plan_pdf"] = plan["href"]110                if batiment:111                    details["building"] = f"Bâtiment {batiment}"112113                ext_id = f"b{batiment or 'x'}-{unit_no}"114                listings.append(Listing(115                    source=self.source_id,116                    external_id=ext_id,117                    url=PAGE_URL,118                    title=f"Unité {unit_no} — Bâtiment {batiment}, "119                          f"Carré des Légendes" if batiment else120                          f"Unité {unit_no} — Carré des Légendes",121                    address="boulevard Saint-Luc, Saint-Jean-sur-Richelieu",122                    city=CITY,123                    unit_type=unit_type,124                    availability=status.get(bid, ""),125                    area_sqft=sqft,126                    amenities=list(dict.fromkeys(amenities))[:15],127                    details=details,128                    images=list(dict.fromkeys(images))[:15],129                ))130            except Exception:131                continue132133        # dédoublonner (le même content_<id> ne devrait sortir qu'une fois)134        uniq: dict[str, Listing] = {}135        for l in listings:136            uniq.setdefault(l.external_id, l)137        return list(uniq.values())138